1. Introduction
In the previous post, I covered how we can show documentation upon hovering on any field in a JSON schema. At this point, we already have all of the lower-level functionality required to navigate the JSON schema as well as the JSON file being edited. This post will directly use those classes to implement completion, aka autocomplete.
In my opinion, completion is vital for one minor and one major reason. The minor reason is that it helps cut down on repeated typing. This may not be as pronounced in the case of ARM templates as it is in other languages. The major reason I consider completion to be critical for a good editor experience is because it provides instant feedback about the correctness of the code you just wrote. If you are writing the name of a property and the completion UI does not show that field as a candidate, you can immediately pause and reassess whether what you are doing is correct. This is critical in the case of ARM templates since there is no compiler to check your files for you. We will implement the completion functionality at two different levels of difficulty. The first will be a very basic completion that will blindly return all fields available at any particular location. The other will take into account the surrounding context, like what fields are already specified and combinators such as AllOf
, OneOf
, and AnyOf
. The latter ends up being rather complex and since my implementation is just for illustrative purposes, you may not always get a correct answer.
You can find the basic implementation by checking out commit 9ede1d5. The comprehensive implementation is available in commit df70890 and will be covered in the next part.
2. Basic Completion
We will create a new handler for completion and implement the required methods. This is what the scaffolding of the class looks like. We create a class called CompletionHandler
and take in the helper classes that we need. We also provide the registration options. One thing to note here is the field TriggerCharacters
. This field takes an array of characters that, when inserted in the editor, automatically trigger the completion UI. This can be different for different languages. In C#, you may want to trigger completion on a .
. In YAML, you may want to trigger completion on a -
. In this case, we are mainly interested in providing completion for keys, which will always be enclosed within double quotes, so we provide it as the trigger character for completion.
namespace Armls.Handlers;
public class CompletionHandler : CompletionHandlerBase
{
private readonly BufferManager bufManager;
private readonly MinimalSchemaComposer schemaComposer;
public CompletionHandler(BufferManager manager, MinimalSchemaComposer schemaComposer)
{
bufManager = manager;
this.schemaComposer = schemaComposer;
}
public override async Task<CompletionList> Handle(
CompletionParams request,
CancellationToken cancellationToken
)
{
/// handle completion
}
protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities)
{
return new CompletionRegistrationOptions{
DocumentSelector = TextDocumentSelector.ForPattern("**/*.json", "**/*.jsonc"),
TriggerCharacters = new string[] { "\"" },
ResolveProvider = false
};
}
}
The base algorithm to provide completion in our server is straightforward.
- Construct a minimal schema for the ARM template.
- Construct a path to the parent node of the cursor in the current file using
JsonPathGenerator
. - Navigate that path in the minimal schema to find all applicable child properties under that parent.
- Return a list of these properties as completion candidates.
One complication here is that when we navigate to the parent node in the minimal schema and find the set of applicable keys, we may find ourselves pointing to a combinator, like OneOf
. To keep things simple, we recursively travel the combinators until we get to a node that has direct child properties. In the end, we combine all results from all branches of the combinator and present a single list to the user. This may show completion candidates that are not always applicable, but this is a good way to get started. We create a method to recursively travel the combinators.
private IEnumerable<CompletionItem> FindCompletionCandidates(JSchema schema)
{
if (schema.AllOf.Count != 0 || schema.AnyOf.Count != 0 || schema.OneOf.Count != 0)
{
return schema.AllOf.Concat(schema.AnyOf).Concat(schema.OneOf)
.SelectMany(childSchema => FindCompletionCandidates(childSchema));
}
return schema.Properties.Select(kvp => new CompletionItem()
{
Label = kvp.Key,
Documentation = new StringOrMarkupContent(kvp.Value.Description ?? "")
});
}
Note that we also send the documentation for a field along with the completion candidate since many editors have provisions to show documentation alongside the completion list. Finally, we are ready to tackle the core of the handler. The code here is a slightly simplified version of what is available in the repository.
public override async Task<CompletionList> Handle(
CompletionParams request,
CancellationToken cancellationToken
)
{
var completionList = new CompletionList();
var buffer = bufManager.GetBuffer(request.TextDocument.Uri);
var schemaUrl = buffer?.GetStringValue("$schema");
var schema = await schemaComposer.ComposeSchemaAsync(schemaUrl, buffer!.GetResourceTypes());
var cursor = new TSPoint{
row = (uint)request.Position.Line,
column = (uint)request.Position.Character,
};
// Schema path contains the path /till/ the last element, which in our case is the field we are trying to write.
// So we get the path only till the parent.
var path = Json.JsonPathGenerator.FromNode(buffer, buffer.ConcreteTree.RootNode().DescendantForPoint(cursor).Parent());
var targetSchema = Schema.SchemaNavigator.FindSchemaByPath(schema, path);
return new CompletionList(FindCompletionCandidates(targetSchema).DistinctBy(c => c.Label + ":" + c.Documentation));
}
At a high level, this method does the following:
- Extracts the schema URL from the buffer and constructs the minimal schema.
- Finds the path to the parent node of the current cursor location.
- Finds the schema node corresponding to the parent node of the cursor.
- Extracts completion candidates recursively from the schema node.
- Deduplicates them to avoid showing two completion items with the same name.
Finally, we register the handler in our MainAsync
method.
var server = await LanguageServer.From(options =>
options
.WithInput(Console.OpenStandardInput())
.WithOutput(Console.OpenStandardOutput())
.WithServices(s =>
s.AddSingleton(new BufferManager()).AddSingleton(new MinimalSchemaComposer())
)
.WithHandler<TextDocumentSyncHandler>()
.WithHandler<HoverHandler>()
.WithHandler<CompletionHandler>() // newly added
);
3. Conclusion
This is how the completion looks in the Emacs UI. VS Code will show a similar UI for completion. Notice that the completion list shows the fields that are already defined in the file. We will see how to tackle this issue in the next part.
Top comments (0)