Skip to content

Instantly share code, notes, and snippets.

@simolus3
Last active March 31, 2023 05:22
Show Gist options
  • Select an option

  • Save simolus3/9c1188d6083a61ce8c1adbb0c91040af to your computer and use it in GitHub Desktop.

Select an option

Save simolus3/9c1188d6083a61ce8c1adbb0c91040af to your computer and use it in GitHub Desktop.
Analyzer plugin - tips and tricks

Analyzer plugins - tips and tricks

Debugging

Analyzer plugins run in a processes started and managed by the analysis server. This is useful when shipping plugins to users, but unfortunate for us as we can't attach a debugger to the plugin process.

Luckily, we can use a little trick to help us here. For debugging purposes, we instead use the following model. The actual plugin will start a websocket server. When the proxy loaded by the analysis server gets started, it will connect to that server and relay all operations. In fact, almost no code changes are necessary to the plugin.

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 = YourSubClassOfServerPlugin();
  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.

Testing

While debugging only requires some additions and almost no changes to existing code, proper testing for plugins is much harder. Personally, I tried to have strong layers of abstraction in my plugin

  • My business logic, which implements analysis services when given an element or ast model from the analyzer

  • a lower-level interface wrapping analyzer apis

    • one of them uses a raw AnalysisDriver, created via the analyzer_plugin

    • one of them uses the build system

    • one of them uses build_test, this is the one I use for testing

I have both an analyzer plugin and a source_gen builder in my Dart package, and I wanted to go for maximum code reuse between them. For inspiration, you can see my interface, the build-based implementation or the one using an analysis driver (see also driver.dart in the same dir). I've written a small wrapper around build_test so that I can declare tests like this.

Technically it should be possible to do all of this with a raw AnalysisDriver, but I didn't have the motivation to do that yet.

Troubleshooting

Why can't I use relative paths in the plugin's pubspec? The analysis server will copy the tools/analyzer_plugin directory into .dartServer/.pluginManager/someHash before running it, so we can't use relative paths in our pubspec.

Nothing ever connects to the server! The first plugin start can take up to a minute, after that it's still around 10 seconds before the analysis server loads the plugin. You can view a set of loaded plugins and what's wrong with them in the analyzer diagnostics website (set dart.analyzerDiagnosticsPort in the VS Code extension). If nothing helps, detailed logs can be written to a file with dart.analyzerInstrumentationLogFile.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment