Tarides Logo
OCaml-related diagram or code.

Advanced Code Navigation in OCaml-LSP

Pizie Dust

Software Engineer

Posted on Wed, 20 Nov 2024

1. Introduction

The Language Server Protocol (LSP) defines a standardised protocol that facilitates communication between an editor (client) and a language server. It is developed by Microsoft and was designed to simplify the process of adding language support to different code editors by abstracting away the most common implementation details of the programming language.

OCaml-LSP, which relies on Merlin's amazing engine, is an implementation of the LSP protocol for the OCaml programming language.

2. Code Navigation in LSP

When it comes to navigating through code, the LSP protocol provides four ways to do this:

While these goto requests provide general navigation for most programming languages, it falls short when dealing with complex syntax such as OCaml's modules, functors, etc., and being able to navigate to specific sections.

3. Code Navigation in Merlin

Merlin offers a range of commands that allow precise code navigation, including the ability to jump to specific constructs like functions, module definitions, and match cases. As earlier seen, generalised navigation in LSP is insufficient for precise movements. To solve this, Merlin introduces a specialised Jump command that allows users to jump to specific targets within their OCaml code.

The key targets include:

  • fun: Jumps to a function definition.
  • let: Jumps to a let binding.
  • module: Jumps to a module.
  • module-type: Jumps to a module type definition.
  • match: Jumps to a match construct.
    • match-next-case: Jumps to the next case in a match statement.
    • match-prev-case: Jumps to the previous case in a match statement.

4. Custom Requests in LSP

When standard LSP requests are insufficient, we have the possibility of writing custom requests to implement the functionality we want. The downside to this approach is that we lose native support in the various editors or clients and will also have to write the resulting client implementations for each custom request. Implementing precise navigation in OCaml-LSP using Merlin's Jump is a typical use for a custom request.

For this implementation, our focus was on being able to use the requests already available on LSP and use them in non-typical, innovative ways. With this in mind, our solution works by using a CodeAction in combination with a ShowDocument request to move the cursor to our desired position.

5. Implementing Merlin's Jump in OCaml-LSP

Code action requests are used to execute commands for a given text document and range. These commands are typically used to change the state of a document, such as code fixes and beautifying or refactoring code. In general, we use code actions to perform quick edits in code. It's a bit unusual to think of code actions as a navigation tool, but with some smart workarounds, we can indeed use code actions to move through code. Here's how the feature works:

  • a) Document Detection: When a source file is opened in the editor, the OCaml-LSP server checks if it is an OCaml file supported by Merlin. If the document is incompatible, the "Jump to Target" functionality is not provided.

  • b) Client Capability Check: The LSP allows servers to query what features a client (the editor) supports. For "Jump to Target" to work, the server checks if the client supports the ShowDocument capability, which is necessary to move the cursor to the correct location.

  • c) Generating CodeActions: The client begins by sending a request for all valid code actions:

[Trace - 06:21:32] Sending request 'textDocument/codeAction - (71)'.
Params: {
    "textDocument": {
        "uri": "file:///.../test.ml"
    },
    "range": {
        "start": {
            "line": 5,
            "character": 12
        },
        "end": {
            "line": 5,
            "character": 12
        }
    },
    "context": {
        "diagnostics": [],
        "triggerKind": 2
    }
}

The server receives this request, and for each target (fun, let, match, module, etc.), it asynchronously queries Merlin's Jump command. It assembles the possible jumps into a list of code actions and returns this list back to the client.

[Trace - 06:21:32] Received response 'textDocument/codeAction - (71)' in 7ms.
Result: [
    ...,
    {
        "command": {
            "arguments": [
                "file:///.../test.ml",
                {
                    "end": {
                        "character": 2,
                        "line": 9
                    },
                    "start": {
                        "character": 2,
                        "line": 9
                    }
                }
            ],
            "command": "ocamllsp/merlin-jump-to-target",
            "title": "Let jump"
        },
        "kind": "merlin-jump-let",
        "title": "Let jump"
    }
]

Given that code actions are a standard LSP functionality, we benefit from an already existing pool of client implementations, so the clients display the codeactions returned by the server without any coding or customisation. Each CodeAction includes a:

  • Title (e.g., "Jump to function").

  • Command (ocamllsp/merlin-jump-to-target): A command that is executed when the code action is selected.

  • Kind: This parameter distinguishes various code actions and can be used to group similar code actions or differentiate them especially for use with keybindings.

  • d) Executing CodeAction Commands: When a user clicks or selects a specific code action, the command associated with that code action is executed. The client sends an executeCommand request to the server.

[Trace - 06:37:10] Sending request 'workspace/executeCommand - (73)'.
Params: {
    "command": "ocamllsp/merlin-jump-to-target",
    "arguments": [
        "file:///.../test.ml",
        {
            "end": {
                "character": 2,
                "line": 9
            },
            "start": {
                "character": 2,
                "line": 9
            }
        }
    ]
}

This executeCommand request triggers the server to ask the client to perform a showDocument request using the document URI and location range received from the code action.

[Trace - 06:37:10] Received request 'window/showDocument - (6)'.
Params: {
    "selection": {
        "end": {
            "character": 2,
            "line": 9
        },
        "start": {
            "character": 2,
            "line": 9
        }
    },
    "takeFocus": true,
    "uri": "file:///.../test.ml"
}

Both executeCommand and showDocument are standard LSP requests, meaning all clients that already have LSP support can automatically perform this jump.

  • e) Error Handling: In cases where Merlin fails to find a valid target (e.g., the target does not exist or the position is invalid), the server gracefully handles the error by omitting the specific code action for that particular target, ensuring a smooth user experience.

6. Feature Demonstration

Below is a demonstration showing the various code actions which perform navigation.

Merlin Jump code actions

7. Conclusion

Set for release in early December 2024, Merlin's Jump functionality in OCaml-LSP brings precision navigation into the LSP world, enhancing OCaml code navigation capabilities across various editors. By using a combination of existing LSP requests, the feature is implemented in a way that is client-agnostic and fully compatible with LSP, ensuring a smooth and efficient user experience for developers working with OCaml.

For developers working on large or complex OCaml projects, this precise navigation capability will significantly streamline workflows, enabling quick and direct navigation to relevant parts of their code.

While implementing Merlin's Jump as a code action offers a broad client-agnostic solution, there are several drawbacks to this approach. First, it can clutter the code action lists, making them noisy for users who are used to a more streamlined selection. Since code actions are typically used for refactoring or fixing issues, adding navigation-related actions here is not an expected usage of code actions. Additionally, binding these navigation actions to shortcuts becomes harder, as code actions do not naturally lend themselves to precise key bindings for navigation.

To overcome these limitations, one potential solution is to use a custom LSP request, which would allow navigation functionality without overloading the code action list. We have a an open PR on ocaml-lsp repository which introduces this custom request. Alternatively, a client-side tool like tree-sitter could be used to parse the code and generate the necessary jump targets directly in the client, offering a more flexible and efficient solution tailored to the user's editor setup.

8. Resources

Open-Source Development

Tarides champions open-source development. We create and maintain key features of the OCaml language in collaboration with the OCaml community. To learn more about how you can support our open-source work, discover our page on GitHub.

Explore Commercial Opportunities

We are always happy to discuss commercial opportunities around OCaml. We provide core services, including training, tailor-made tools, and secure solutions. Tarides can help your teams realise their vision