Implementing a Language Server with Language Server Protocol - Hover (Part 4)

By Samvid Mistry

August 9, 2025

1. Introduction

In the previous post, we created minimal schemas for ARM templates that help validate their structure semantically. In this post, we will implement hover support using LSP. It involves:

You can check out commit 78372cc to follow along. The result will look like this:

Hover demonstration

2. Path to Root

We construct the path from the node under the cursor to the root of the document. This ensures we always find documentation for the correct field, even when multiple fields share the same name. We create a utility class called JsonPathGenerator to generate this path. Our schema only has two types of containers: pair and array. In either case we record the address of the current field in that container. For pairs, we record the key. For arrays, we record the index of the element inside the array. Recording the index lets us detect arrays during schema traversal. If a segment in the path is an index, we know the next element is inside an array and we should look at the schema of the array's items rather than the array itself.

public static class JsonPathGenerator
{
    public static List<string> FromNode(Buffer.Buffer buffer, TSNode startNode)
    {
        var path = new List<string>();
        var currentNode = startNode;
        while ((currentNode = currentNode.Parent()) != null)
        {
            if (currentNode.Type == "pair")
            {
                var keyNode = currentNode.ChildByFieldName("key");
                if (keyNode != null)
                {
                    path.Insert(0, keyNode.Text(buffer.Text).Trim('"'));
                }
            }
            else if (currentNode.Type == "array")
            {
                uint index = 0;
                for (uint i = 0; i < currentNode.NamedChildCount; i++)
                {
                    if (currentNode.NamedChild(i).Equals(startNode))
                    {
                        index = i;
                        break;
                    }
                }
                path.Insert(0, index.ToString());
            }
        }
        return path;
    }
}

3. Schema Traversal

Once we have the path to the element, we traverse the schema in the same order to locate the schema for the field under the cursor. We write another utility class for this purpose, called SchemaNavigator. The flow mirrors the path construction, with one wrinkle. The second half of the function simply checks whether the current path segment exists in the properties of the current schema object. If it doesn't, and the segment represents an array, we look for the segment in the definition of the array's items. The first half handles JSON Schema combinators. A property can be defined in terms of a combination of other properties using anyOf, allOf, or oneOf. If we encounter these, we perform a depth-first search through the schemas to find the field. This straightforward approach gets the point across for this article, though it may not be ideal for production software.

public static class SchemaNavigator
{
    public static JSchema? FindSchemaByPath(JSchema rootSchema, List<string> path)
    {
        JSchema? currentSchema = rootSchema;

        for (int i = 0; i < path.Count(); i++)
        {
            var segment = path[i];
            if (currentSchema == null) return null;

            IList<JSchema> combinator = null;
            if (currentSchema.AnyOf.Count > 0) { combinator = currentSchema.AnyOf; }
            else if (currentSchema.AllOf.Count > 0) { combinator = currentSchema.AllOf; }
            else if (currentSchema.OneOf.Count > 0) { combinator = currentSchema.OneOf; }

            if (combinator is not null)
            {
                foreach (var schemaPath in combinator)
                {
                    var nestedSchema = FindSchemaByPath(schemaPath, path.Skip(i).ToList());
                    if (nestedSchema is not null) return nestedSchema;
                }
                return null; // Path segment not found in any of the choices
            }

            if (currentSchema.Properties.TryGetValue(segment, out var propertySchema))
            {
                currentSchema = propertySchema;
            }
            // If the segment is an integer, attempt to navigate into an array.
            else if (currentSchema.Type == JSchemaType.Array && int.TryParse(segment, out _))
            {
                // For ARM templates, arrays usually have a single schema definition for all their items.
                if (currentSchema.Items.Count > 0) currentSchema = currentSchema.Items[0];
                else return null; // Array schema has no item definition.
            }
            else return null; // Path segment not found.
        }

        return currentSchema;
    }
}

4. Hover Handler

Now let's put it all together. We'll define a HoverHandler that finds the symbol under the cursor, constructs the path, traverses the schema, and returns the hover content. It needs access to the BufferManager to read the buffer text and to the MinimalSchemaComposer from the previous article to build a minimal schema that includes documentation.

public class HoverHandler : HoverHandlerBase
{
    private BufferManager bufManager;
    private MinimalSchemaComposer schemaComposer;

    public HoverHandler(BufferManager manager, MinimalSchemaComposer schemaComposer)
    {
        bufManager = manager;
        this.schemaComposer = schemaComposer;
    }

    public override async Task<Hover?> Handle(
        HoverParams request,
        CancellationToken cancellationToken
    )
    {
        var buffer = bufManager.GetBuffer(request.TextDocument.Uri);
        var schemaUrl = buffer.GetStringValue("$schema");
        var schema = await schemaComposer.ComposeSchemaAsync(schemaUrl, buffer.GetResourceTypes());
        var cursorPosition = new TSPoint()
        {
            row = (uint)request.Position.Line,
            column = (uint)request.Position.Character,
        };

        var rootNode = buffer.ConcreteTree.RootNode();
        var hoveredNode = rootNode.DescendantForPointRange(cursorPosition, cursorPosition);
        var path = Json.JsonPathGenerator.FromNode(buffer, hoveredNode);
        var targetSchema = Schema.SchemaNavigator.FindSchemaByPath(schema, path);
        return new Hover
        {
            Contents = new MarkedStringsOrMarkupContent(
                new MarkupContent { Kind = MarkupKind.Markdown, Value = targetSchema.Description }
            ),
            Range = hoveredNode.GetRange(),
        };
    }
}

Finally, add the handler to the language server configuration in Program.cs.

private static async Task MainAsync()
{
    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>()
    );

    await server.WaitForExit;
}

5. Conclusion

There you have it: a straightforward way to provide hover documentation. The JSON Schema for a document contains a wealth of information that editors can use to provide various features. In the next post, we'll look at providing auto-completion through LSP. As a reminder, the first post in this series explains how to use the Armls VS Code extension to interact with Armls.