Skip to content

Analyzer PluginでLintを自作する

2021/12/09

Dart

目次

目次

  1. はじめに
  2. この記事で伝えたいこと
  3. Analyzer 周りのツールについて
  4. Analyzer Plugin について
  5. Analyzer Plugin で自作の Lint を作る方法
    1. Dart のプロジェクトを作る
    2. Analyzer Plugin の呼び出しに必要なファイルを配置
    3. プラグインの実装
  6. 注意点
  7. おわりに

はじめに

この記事は Flutter Advent Calendar 2021(カレンダー 1)の 9 日目の記事です。

こんにちは kawa(@dev_kawa)です。

コーディング規約は、チームでルールを作り誰が見ても理解できるコードを書くことを目的として導入されます。

一方でコーディング規約が守られているかを担保するために、コードレビューのコストが増加してしまうという課題が発生します。

レビューコストの増加かつコーディング規約が守られない場合、目的が達成されずコードレビューだけが辛くなります。

flutter analyze を実行することで、ある程度は解決できますが、こんな Lint があれば便利なのにというケースがあります。

本記事は、そうした状況を改善するために、Flutter(Dart)において静的解析を自作する方法を紹介します。

vscode

(このように表示できます)

お気持ちスター(⭐)で喜びます 😻

https://github.com/kawa1214/import-lint

https://pub.dev/packages/import_lint

この記事で伝えたいこと

Analyzer 周りのツールについて

Analyzer は Dart の静的解析をします。

Analysis Server は、DartSDK に含まれているツールの 1 つです。

Analysis Server から送られる JSON 形式のデータを、VSCode の Dart Extension を介して受け取り、VSCode にエラーとして表示できます。(dart code)

Analyzer Plugin について

Analyzer Plugin は Analysis Server のプラグインを作成するためのフレームワークです。

こちらを用いて Lint を作ります。

Analyzer Plugin で自作の Lint を作る方法

Dart のプロジェクトを作る

dart create で dart のプロジェクトを作成します。

dart create hoge_analyzer_plugin

Analyzer Plugin の呼び出しに必要なファイルを配置

Analysis Server は<host_package>/tools/analyzerpluginに存在するプロジェクトを読み込みます。プロジェクトに必要なファイルを配置します。

tools/analyzer_plugin/pubspec.yaml

name: hoge_analyzer_plugin_loader
description: bootstrap package of hoge_analyzer_plugin
version: 1.0.0
publish_to: none

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  hoge_analyzer_plugin:
    path: /xxx/hoge_analyzer_plugin
# hoge_analyzer_plugin ディレクトリまでのパス
# pub.devに公開する際はバージョンを指定する

tools/analyzer_plugin/bin/plugin.dart

import 'dart:isolate';

import 'package:hoge_analyzer_plugin/plugin_starter.dart' as plugin;

void main(List<String> args, SendPort sendPort) {
  plugin.start(args, sendPort);
}

lib/plugin_starter.dart

import 'dart:isolate';

import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer_plugin/starter.dart';

import 'plugin.dart';

void start(Iterable<String> _, SendPort sendPort) {
  ServerPluginStarter(HogeAnalyzerPlugin(PhysicalResourceProvider.INSTANCE))
      .start(sendPort);
}

プラグインの実装

次にプラグイン本体の実装を進めます。

ServerPluginを継承したHogeAnalyzerPluginを作成します。

createAnalysisDriverでドライバーを作成します。ここで、情報を受け取り、分析結果を通知する処理を行います。

lib/plugin.dart

import 'dart:async';

import 'package:analyzer/dart/analysis/context_builder.dart';
import 'package:analyzer/dart/analysis/context_locator.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/dart/analysis/driver.dart';
import 'package:analyzer/src/dart/analysis/driver_based_analysis_context.dart';
import 'package:analyzer_plugin/plugin/plugin.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;

class HogeAnalyzerPlugin extends ServerPlugin {
  HogeAnalyzerPlugin(ResourceProvider provider) : super(provider);

  var _filesFromSetPriorityFilesRequest = <String>[];

  // プラグインがAnalyzeの対象とするファイルをGlobで記述します
  @override
  List<String> get fileGlobsToAnalyze => <String>['/lib/**/*.dart'];

  // プラグインの名前を記述します
  @override
  String get name => 'Hoge Analyzer Plugin';

  // Server APIのバージョンを記述します
  @override
  String get version => '1.0.0';

  @override
  AnalysisDriverGeneric createAnalysisDriver(plugin.ContextRoot contextRoot) {
    final rootPath = contextRoot.root;
    final locator =
        ContextLocator(resourceProvider: resourceProvider).locateRoots(
      includedPaths: [rootPath],
      excludedPaths: [
        ...contextRoot.exclude,
      ],
      optionsFile: contextRoot.optionsFile,
    );

    if (locator.isEmpty) {
      final error = StateError('Unexpected empty context');
      channel.sendNotification(plugin.PluginErrorParams(
        true,
        error.message,
        error.stackTrace.toString(),
      ).toNotification());

      throw error;
    }

    final builder = ContextBuilder(
      resourceProvider: resourceProvider,
    );

    final analysisContext = builder.createContext(contextRoot: locator.first);
    final context = analysisContext as DriverBasedAnalysisContext;
    final dartDriver = context.driver;

    runZonedGuarded(
      () {
        dartDriver.results.listen((analysisResult) {
          if (analysisResult is ResolvedUnitResult) {

            final errors = _check(
              analysisResult: analysisResult,
            );

            channel.sendNotification(
              plugin.AnalysisErrorsParams(
                path,
                errors,
              ).toNotification(),
            );
          } else if (analysisResult is ErrorsResult) {
            channel.sendNotification(plugin.PluginErrorParams(
              false,
              'ErrorResult $analysisResult',
              '',
            ).toNotification());
          }
        });
      },
      (Object e, StackTrace stackTrace) {
        channel.sendNotification(
          plugin.PluginErrorParams(
            false,
            'Unexpected error: ${e.toString()}',
            stackTrace.toString(),
          ).toNotification(),
        );
      },
    );
    return dartDriver;
  }

  @override
  void contentChanged(String path) {
    super.driverForPath(path)?.addFile(path);
  }

  @override
  Future<plugin.AnalysisSetPriorityFilesResult> handleAnalysisSetPriorityFiles(
    plugin.AnalysisSetPriorityFilesParams parameters,
  ) async {
    _filesFromSetPriorityFilesRequest = parameters.files;
    _updatePriorityFiles();

    return plugin.AnalysisSetPriorityFilesResult();
  }

  @override
  Future<plugin.AnalysisSetContextRootsResult> handleAnalysisSetContextRoots(
    plugin.AnalysisSetContextRootsParams parameters,
  ) async {
    final result = await super.handleAnalysisSetContextRoots(parameters);
    return result;
  }

  void _updatePriorityFiles() {
    final filesToFullyResolve = {
      ..._filesFromSetPriorityFilesRequest,
      for (final driver2 in driverMap.values)
        ...(driver2 as AnalysisDriver).addedFiles,
    };

    final filesByDriver = <AnalysisDriverGeneric, List<String>>{};
    for (final file in filesToFullyResolve) {
      final contextRoot = contextRootContaining(file);
      if (contextRoot != null) {
        final driver = driverMap[contextRoot];
        if (driver != null) {
          filesByDriver.putIfAbsent(driver, () => <String>[]).add(file);
        }
      }
    }
    filesByDriver.forEach((driver, files) {
      driver.priorityFiles = files;
    });
  }

  List<plugin.AnalysisError> _check(
    ResolvedUnitResult analysisResult,
  ) {
    // TODO: 実装
    // The Element Model
    // https://github.com/dart-lang/sdk/blob/e995cb5f7cd67d39c1ee4bdbe95c8241db36725f/pkg/analyzer/doc/tutorial/element.md

    // ここでエラー情報を返す
    return [];
  }
}

抽象構文木を用いて具体的な実装をしますが今回は割愛します。

通知するエラー以下のようになります。

final location =  plugin.Location(
      filePath,
      offset,
      length,
      startLine,
      startColumn,
    );

final error =  plugin.AnalysisError(
      plugin.AnalysisErrorSeverity('INFO'),
      plugin.AnalysisErrorType.LINT,
      location,
      message,
      code,
      correction: 'Try ...',
      hasFix: false,
    );

AnalysisErrorSeverity: エラーの深刻度を INFO, WARNING, ERROR のレベルで指定できます。

AnalysisErrorType: エラーの種類を指定できます。

CHECKED_MODE_COMPILE_TIME_ERROR,
COMPILE_TIME_ERROR,
HINT,
LINT,
STATIC_TYPE_WARNING,
STATIC_WARNING,
SYNTACTIC_ERROR,
TODO,

message: 文字列型でコードのどこが間違っているかを示します。

code: 文字列型でエラーコードを表示します。

correction: 文字列型で修正する方法を示します。

注意点

flutter analyze
or
dart analyze

analyze コマンドでは、プラグインで作成した情報は表示できないため、別途 CLI の実装が必要です。

おわりに

ざっくりと analyzer plugin について書きました(アドベントカレンダーギリギリで書いてしまう)。