Simon Binder

Debugging analyzer plugins

Analyzer plugins can be used to extend the Dart analysis server used by all Dart editors. They can provide new lints and fixes for Dart or analysis support for other programming languages. Analyzer plugins are loaded and managed by the Dart analysis server, making them easy to distribute and use. This approach has a downside though: Since the analyzer is responsible to load plugins, they're rather tough to debug - we can't just run our main method from the IDE.

Luckily, we can use a little trick to help us here. For debugging purposes, we instead use the following model:

All we need to do is implement the different PluginCommunicationChannel - almost no code changes are needed to the actual plugin here.

First, let's write the proxy that can be loaded by the analysis server.

# tool/analyzer_plugin/pubspec.yaml
name: analyzer_load_my_plugin
version: 1.0.0

dependencies:
 your_real_plugin: ^2.0.0 # remove if you don't have a published analyzer plugin yet, or use an (absolute!) path dependency
 web_socket_channel: ^1.0.15
// tool/analyzer_plugin/bin/plugin.dart
import 'dart:convert';
import 'dart:isolate';

import 'package:your_real_plugin/plugin.dart' as plugin;
import 'package:web_socket_channel/io.dart';

const useDebuggingVariant = true; // set to false before publishing

void main(List<String> args, SendPort sendPort) {
  if (useDebuggingVariant) {
    _PluginProxy(sendPort).start();
  } else {
    // start the regular plugin like you normally would
    plugin.start(args, sendPort);
  }
}

class _PluginProxy {
  final SendPort sendToAnalysisServer;

  ReceivePort _receive;
  IOWebSocketChannel _channel;

  _PluginProxy(this.sendToAnalysisServer);

  Future<void> start() async {
    _channel = IOWebSocketChannel.connect('ws://localhost:9999');
    _receive = ReceivePort();
    sendToAnalysisServer.send(_receive.sendPort);

    _receive.listen((data) {
      // the server will send messages as maps, convert to json
      _channel.sink.add(json.encode(data));
    });

    _channel.stream.listen((data) {
      sendToAnalysisServer.send(json.decode(data as String));
    });
  }
}

Next, we need to adapt the plugin so that it opens the websocket server. For that, we'll write a drop-in replacement for ServerPluginStarter:

class _WebSocketPluginServer implements PluginCommunicationChannel {
  final dynamic address;
  final int port;

  HttpServer server;

  WebSocket _currentClient;
  final StreamController<WebSocket> _clientStream =
      StreamController.broadcast();

  _WebSocketPluginServer({dynamic address, this.port = 9999})
      : address = address ?? InternetAddress.loopbackIPv4 {
    _init();
  }

  Future<void> _init() async {
    server = await HttpServer.bind(address, port);
    print('listening on $address at port $port');
    server.transform(WebSocketTransformer()).listen(_handleClientAdded);
  }

  void _handleClientAdded(WebSocket socket) {
    if (_currentClient != null) {
      print('ignoring connection attempt because an active client already '
          'exists');
      socket.close();
    } else {
      print('client connected');
      _currentClient = socket;
      _clientStream.add(_currentClient);
      _currentClient.done.then((_) {
        print('client disconnected');
        _currentClient = null;
        _clientStream.add(null);
      });
    }
  }

  @override
  void close() {
    server?.close(force: true);
  }

  @override
  void listen(void Function(Request request) onRequest,
      {Function onError, void Function() onDone}) {
    final stream = _clientStream.stream;

    // wait until we're connected
    stream.firstWhere((socket) => socket != null).then((_) {
      _currentClient.listen((data) {
        print('I: $data');
        onRequest(Request.fromJson(
            json.decode(data as String) as Map<String, dynamic>));
      });
    });
    stream.firstWhere((socket) => socket == null).then((_) => onDone());
  }

  @override
  void sendNotification(Notification notification) {
    print('N: ${notification.toJson()}');
    _currentClient?.add(json.encode(notification.toJson()));
  }

  @override
  void sendResponse(Response response) {
    print('O: ${response.toJson()}');
    _currentClient?.add(json.encode(response.toJson()));
  }
}

You can then write a new Dart file where the main method looks like

void main() {
  final plugin = YourAnalysisServerPlugin();
  plugin.start(_WebSocketPluginServer());    
}

To debug the plugin, all you need to do is

  1. Run the file with the main method from an IDE. You have full control over that process, so you can debug it.
  2. Start another IDE with code that uses the plugin. It will start an analysis server that loads the proxy, which will then connect to your server.

The server will also print incoming and outgoing messages, which can be useful to find errors.