Writing a quick assist

This document describes what a quick assist is and outlines the basic steps for writing a quick assist.

Overview

A quick assist is an automated code edit that is both local in scope and doesn't require any user input. By ‘local in scope’ we mean that the assist only needs information from the local library (the library in which it is invoked) and will only make changes to the local library. If a code edit requires user input, might depend on knowledge from outside the local library, or could make changes outside the local library, then it should probably be implemented using a refactoring.

Unlike quick fixes, quick assists are displayed even when there are no diagnostics being reported against the code. They are not intended to fix problems that are being reported, but are used to perform common and straightforward code transformations.

In this document we‘ll use a simple example of writing an assist to convert a decimal representation of an integer into a hexadecimal representation. While this wouldn’t be a good assist to offer, it‘s simple enough that the details of the implementation won’t mask the general process used to implement an assist.

Design considerations

Because quick assists are computed on demand, they need to be computed quickly. (Some clients request quick assists every time the user repositions the cursor.) That places a performance requirement on quick assists, one that requires that the code to compute a quick assist can‘t perform any potentially lengthy computations such as searching all of the user’s code or accessing the network. That, in turn, generally means that assists can only support localized changes. They can add or remove text in the local library, but generally can't do more than that.

Unlike quick fixes, there is no signal to indicate which assists might apply at a given location in the code. That means that we have to test every assist to see whether it's appropriate, which puts a practical limit on the number of assists that we can implement. (Even if each assist only takes 100 milliseconds to determine whether it applies, if we have 100 assists it will take 10 seconds to return the list of assists to the user, which is too slow.) That means that we need to be discerning about which assists are implemented and work to make assists return quickly if they are not appropriate.

Describing the assist

Each assist has an instance of the class AssistKind associated with it. The existing assists for Dart are defined in the class DartAssistKind. An assist kind has an identifier, a priority, and a message.

The identifier is used by some LSP-based clients to provide user-defined shortcuts. It's a hierarchical dot-separated identifier and should follow the pattern seen in the existing assist kinds.

The priority is used to order the list of assists when presented to the user. The larger the value the closer to the top of the list it will appear. If you‘re implementing an assist for Dart files, you should use one of the constants defined in DartAssistKindPriority (typically DartAssistKindPriority.DEFAULT), or add a new constant if there’s a need for it.

The message is what will be displayed to the user by the client. This should be a sentence fragment (no terminating period) that would be appropriate as a label in a menu or on a button. It should describe the change that will be made to the user's code.

Create a static field whose value is an AssistKind describing the assist you're implementing. For our example you might define a new constant in DartAssistKind like this:

static const CONVERT_TO_HEX = AssistKind(
  'dart.assist.convert.toHex',
  DartAssistKindPriority.DEFAULT,
  "Convert to hexadecimal",
);

Implementing the assist, part 1

To implement the assist you‘ll create a subclass of CorrectionProducer. Most of the existing correction producers are in the directory analysis_server/lib/src/services/correction/dart, so we’ll start by creating a file named convert_to_hex.dart in that directory that contains the following (with the year updated appropriately):

// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:analysis_server/src/services/correction/assist.dart';
import 'package:analysis_server/src/services/correction/dart/abstract_producer.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer_plugin/utilities/assist/assist.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:analyzer_plugin/utilities/range_factory.dart';

class ConvertToHex extends CorrectionProducer {
  @override
  AssistKind get assistKind => DartAssistKind.CONVERT_TO_HEX;

  @override
  Future<void> compute(ChangeBuilder builder) async {
  }
}

The compute method is where the assist will be built. We'll come back to it in “Implementing the assist, part 2”.

The assistKind getter is how you associate the assist kind we created earlier with the assist produced by the compute method.

There‘s another getter you might need to override. The message associated with the assist kind is actually a template that can be filled in at runtime. The placeholders in the message are denoted by integers inside curly braces (such as {0}). The integers are indexes into a list of replacement values (hence, zero based), and the getter assistArguments returns the list of replacement values. The message we used above doesn’t have any placeholders, but if we'd written the message as "Convert to '{0}'", then we could return the replacement values by implementing:

String _hexRepresentation = '';

@override
List<Object> get assistArguments => [_hexRepresentation];

and assigning the replacement string to the field inside the compute method.

If you don‘t implement this getter, then the inherited getter will return an empty list. The number of elements in the list must match the largest index used in the message. If it doesn’t, an exception will be thrown at runtime.

Testing the assist

Before we look at implementing the compute method, we should probably write some tests. Even if you don‘t normally use a test-driven approach to coding, we recommend it in this case because writing the tests can help you think of corner cases that the implementation will need to handle. The corresponding tests are in the directory analysis_server/test/src/services/correction/assist, so we’ll create a file named convert_to_hex_test.dart that contains the following:

// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:analysis_server/src/services/correction/assist.dart';
import 'package:analyzer_plugin/utilities/assist/assist.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

import 'assist_processor.dart';

void main() {
  defineReflectiveSuite(() {
    defineReflectiveTests(ConvertToHexTest);
  });
}

@reflectiveTest
class ConvertToHexTest extends AssistProcessorTest {
  @override
  AssistKind get kind => DartAssistKind.CONVERT_TO_HEX;
}

This getter tells the test framework to expect an assist of the returned kind.

The test can then be written in a method that looks something like this:

Future<void> test_positive() async {
  await resolveTestCode('''
var c = /*caret*/42;
''');
  await assertHasAssist('''
var c = 0x2a;
''');
}

The test framework will look for the marker /*caret*/, remember it's offset, create a file containing the first piece of code with the marker removed, run the assist over the code, use our correction producer to build an edit, apply the edit to the file, and textually compare the results with the second piece of code.

Registering the assist

Before we can run the test, we need to register the correction producer so that it can be run.

The list of assists is computed by the AssistProcessor, which has two static lists named generators and multiGenerators that contain the correction producers used to compute the assists.

Actually, the lists contain functions used to create the producers. We do that so that producers can‘t accidentally carry state over from one use to the next. These functions are usually a tear-off of the correction producer’s constructor.

The last step is to add your correction producer to the list named generators. We'll talk about the other list in “Multi-assist producers”.

At this point you should be able to run the test and see it failing.

Implementing the assist, part 2

We're now at a point where we can finish the implementation of the assist by implementing the compute method.

The correction producer has access to most of the information you should need in order to write the assist. The change builder passed to compute is how you construct the edit that will be sent back to the client.

The first step in the implementation of any assist is to find the location in the AST where the cursor is positioned and verify that all the conditions on which the assist is predicated are valid. For example, for our assist we'll need to ensure that the cursor is on an integer literal, that the literal consists of decimal digits, and that the value is a valid integer.

Finding the AST node is easy because it's done for you by the assist processor before compute is invoked. All you have to do is use the getter node to find the node at the cursor.

@override
Future<void> compute(ChangeBuilder builder) async {
  var node = this.node;
}

Then we need to verify that this node is an integer literal:

@override
Future<void> compute(ChangeBuilder builder) async {
  var node = this.node;
  if (node is! IntegerLiteral) {
    return;
  }
}

If it isn‘t, then we’ll return. Because we haven't used the builder to create a assist, returning now means that no assist from this producer will be sent to the client.

We'll also check that the integer has the right form and is valid:

@override
Future<void> compute(ChangeBuilder builder) async {
  var node = this.node;
  if (node is! IntegerLiteral) {
    return;
  }
  var value = node.value;
  if (value == null) {
    return;
  }
  if (node.literal.lexeme.contains(RegExp('[^0-9]'))) {
    return;
  }
}

After all those checks we now know that we have a decimal integer that we can convert. Note that we check for a null value before checking for non-decimal digits because it's a faster check and we want the assist to fail as quickly as possible.

We‘re now ready to create the edit. To do that we’re going to use the ChangeBuilder passed to the compute method. In the example below we'll introduce a couple of the methods on ChangeBuilder, but for more information you can read Creating SourceChanges.

@override
Future<void> compute(ChangeBuilder builder) async {
  var node = this.node;
  if (node is! IntegerLiteral) {
    return;
  }
  var value = node.value;
  if (value == null) {
    return;
  }
  if (node.literal.lexeme.contains(RegExp('[^0-9]'))) {
    return;
  }
  await builder.addDartFileEdit(file, (builder) {
    var hexDigits = value.toRadixString(16);
    builder.addSimpleReplacement(range.node(node), '0x$hexDigits');
  });
}

We‘re using addDartFileEdit to create an edit in a .dart file. In this case the edit is simple: we’re just replacing one representation of the integer literal with a representation of the same value in a different base. The getter range returns a RangeFactory, a utility class with lots of methods to make it easier to create SourceRanges.

We‘re missing several test cases. Minimally we should test that the assist works correctly with negative values (it doesn’t), and that it will not produce an edit if the integer literal isn‘t valid. We’ll leave adding such tests as an exercise for the reader.

In this example we‘re just making a simple replacement, so we’re avoiding any need to worry about formatting. As a general principle we don‘t attempt to format the code after it’s been modified, but we do make an effort to leave the code in a reasonably readable state. There‘s a getter (eol) that you can use to get the end-of-line marker that should be used in the file, and there’s another getter (utils) that will return an object with several utility methods that help with things like getting the right indentation for nested code.

Multi-assist producers

We skipped over the list named multiGenerators earlier, promising that we‘d return to it later. You’ll probably never have a need for it, but in case you do this section will hopefully tell you what you need to know.

There‘s a subclass of CorrectionProducer named MultiCorrectionProducer and this list is how you register one of them. That class exists for rare cases where you need to use a single correction producer to produce multiple assists. This is generally only needed when you can’t know in advance the maximum number of assists that might need to be produced. For example, there's a set of assists to wrap a Flutter Widget in another widget, but the set of widgets that can wrap a given widget depends on the widget being wrapped.

If you are able to enumerate the possible assists ahead of time, then you're probably better off to create one subclass of CorrectionProducer for each of the assists.