Skip to content

Possibly add support for clangd's textDocument/switchSourceHeader ? #5007

@rymdbar

Description

@rymdbar

Background

This is not a feature request. I'm not even entirely sure its a desirable feature, but I might as well post this since I did spend some time on experimenting with it while trying to get clangd to behave properly.

As is documented, clangd supposedly implements its own custom LSP commands. Among them is one to switch between header file and source file. Presumably because their own implementation of textDocument/definition is failing to meet expectations, regardless of whether using a compile_commands.json database or not.

While not a fix, the correct path is likely often to use another tool than ale. Some kind of dedicated interactive LSP debugger able to send arbitrary methods to a language server for analyzing its response. Pointers towards existing such tools are welcome and to be considered being on-topic replies.

Example

Consider this personal pet project of mine, having these files:

hello.cpp
#include "greet.hpp"

int main() {
    say_it();
    return 0;
}
greet.hpp
#pragma once

void say_it();
greet.hpp
#include <iostream>
#include "greet.hpp"

void say_it() {
    std::cout << "Hello C++!" << std::endl;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.10)

project(HelloWorld LANGUAGES CXX)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

add_executable(hello
    hello.cpp
    greet.cpp
)

add_custom_command(TARGET hello PRE_BUILD
    COMMENT "Ensure clangd file is up to date."
    COMMAND ${CMAKE_COMMAND} -E copy
        ${CMAKE_BINARY_DIR}/compile_commands.json ${CMAKE_SOURCE_DIR}/
)

Changes to ale

To make things interesting, lets patch ale. This is of course not a pull request in disguise, but code as basis for further discussion. A lot of stuff is obviously wrong. Not only the fact that ale#lsp#HasCapability() is forced to always return 1.

Patching ale (dirty hack, brokenly attempting to implement this stuff)
diff --git a/autoload/ale/definition.vim b/autoload/ale/definition.vim
index e58a5642..300744dd 100644
--- a/autoload/ale/definition.vim
+++ b/autoload/ale/definition.vim
@@ -229,6 +229,8 @@ function! s:OnReady(line, column, options, capability, linter, lsp_details) abor
             let l:message = ale#lsp#message#TypeDefinition(l:buffer, a:line, a:column)
         elseif a:capability is# 'implementation'
             let l:message = ale#lsp#message#Implementation(l:buffer, a:line, a:column)
+        elseif a:capability is# 'switchsourceheader'
+            let l:message = ale#lsp#message#SwitchSourceHeader(l:buffer)
         else
             " XXX: log here?
             return
@@ -272,6 +274,12 @@ function! ale#definition#GoToImpl(options) abort
     endfor
 endfunction
 
+function! ale#definition#SwitchSourceHeader(options) abort
+    for l:linter in ale#lsp_linter#GetEnabled(bufnr(''))
+        call s:GoToLSPDefinition(l:linter, a:options, 'switchsourceheader')
+    endfor
+endfunction
+
 function! ale#definition#GoToCommandHandler(command, ...) abort
     let l:options = {}
 
@@ -299,6 +307,8 @@ function! ale#definition#GoToCommandHandler(command, ...) abort
         call ale#definition#GoToType(l:options)
     elseif a:command is# 'implementation'
         call ale#definition#GoToImpl(l:options)
+    elseif a:command is# 'switchsourceheader'
+        call ale#definition#SwitchSourceHeader(l:options)
     else
         call ale#definition#GoTo(l:options)
     endif
diff --git a/autoload/ale/lsp.vim b/autoload/ale/lsp.vim
index 07b073f8..7ba5b124 100644
--- a/autoload/ale/lsp.vim
+++ b/autoload/ale/lsp.vim
@@ -490,6 +490,9 @@ function! s:SendInitMessage(conn) abort
     \               'typeDefinition': {
     \                   'dynamicRegistration': v:false,
     \               },
+    \               'switchSourceHeader': {
+    \                   'dynamicRegistration': v:false,
+    \               },
     \               'implementation': {
     \                   'dynamicRegistration': v:false,
     \                   'linkSupport': v:false,
@@ -837,6 +840,7 @@ endfunction
 
 " Check if an LSP has a given capability.
 function! ale#lsp#HasCapability(conn_id, capability) abort
+    return 1
     let l:conn = get(s:connections, a:conn_id, {})
 
     if empty(l:conn)
diff --git a/autoload/ale/lsp/message.vim b/autoload/ale/lsp/message.vim
index 72ed7d59..36fc43f3 100644
--- a/autoload/ale/lsp/message.vim
+++ b/autoload/ale/lsp/message.vim
@@ -126,6 +126,14 @@ function! ale#lsp#message#Definition(buffer, line, column) abort
     \}]
 endfunction
 
+" https://clangd.llvm.org/extensions#switch-between-sourceheader
+function! ale#lsp#message#SwitchSourceHeader(buffer) abort
+    return [0, 'textDocument/switchSourceHeader', {
+    \   'TextDocumentIdentifier':
+    \       ale#util#ToURI(expand('#' . a:buffer . ':p')),
+    \}]
+endfunction
+
 function! ale#lsp#message#TypeDefinition(buffer, line, column) abort
     return [0, 'textDocument/typeDefinition', {
     \   'textDocument': {
diff --git a/plugin/ale.vim b/plugin/ale.vim
index ba702956..5b0101b0 100644
--- a/plugin/ale.vim
+++ b/plugin/ale.vim
@@ -297,6 +297,8 @@ command! -bar -nargs=* ALEGoToTypeDefinition :call ale#definition#GoToCommandHan
 " Go to implementation for tsserver and LSP
 command! -bar -nargs=* ALEGoToImplementation :call ale#definition#GoToCommandHandler('implementation', <f-args>)
 
+command! -bar -nargs=* ALESwitchSourceHeader :call ale#definition#GoToCommandHandler('switchsourceheader', <f-args>)
+
 " Repeat a previous selection in the preview window
 command! -bar ALERepeatSelection :call ale#preview#RepeatSelection()
 

Preparing and entering vim

You could try building and running this project to get a friendly greeting, and also to ensure compile_commands.json gets generated alright. Then opening up the main file in a vim with ale patched in accordance with above:

mkdir build && cd build && cmake .. && make
./hello
cd ..
gvim hello.cpp

Now it is time to start saving your LSP logs if following along interactively. I have ALESaveLSPLog for this and it should be contributed somewhere in the comments of some other issue, using call ch_logfile('/tmp/chlogfile') + StopAllLSPs ought to work just as well.

Communication of course starts out with some initial messages between ale and clangd: (some verbose obviously uninteresting things are stripped out of the initial message, and a few fully unrelated messages are completely left out.)

The outgoing initialize message contains the added capability, but I'm not sure its named correctly or has valid arguments.

id 4: initialize, ale → clangd
{
    "method": "initialize",
    "jsonrpc": "2.0",
    "id": 4,
    "params": {
        "initializationOptions": {},
        "rootUri": "file:///tmp/hello",
        "capabilities": {
            
            "textDocument": {
                
                "switchSourceHeader": {
                    "dynamicRegistration": false
                },
                
            }
        },
        "rootPath": "/tmp/hello",
        "processId": 46392
    }
}

The response does not appear to mention the capability, as far as I can see. That could be due to an invalid initialization request, or due to the feature missing. Likely the first, given the exact details of the response when attempting to use it.

id 4: full response, ale ← clangd
{
    "id": 4,
    "jsonrpc": "2.0",
    "result": {
        "capabilities": {
            "astProvider": true,
            "callHierarchyProvider": true,
            "clangdInlayHintsProvider": true,
            "codeActionProvider": {
                "codeActionKinds": [
                    "quickfix",
                    "refactor",
                    "info"
                ]
            },
            "compilationDatabase": {
                "automaticReload": true
            },
            "completionProvider": {
                "resolveProvider": false,
                "triggerCharacters": [
                    ".",
                    "<",
                    ">",
                    ":",
                    "\"",
                    "/",
                    "*"
                ]
            },
            "declarationProvider": true,
            "definitionProvider": true,
            "documentFormattingProvider": true,
            "documentHighlightProvider": true,
            "documentLinkProvider": {
                "resolveProvider": false
            },
            "documentOnTypeFormattingProvider": {
                "firstTriggerCharacter": "\n",
                "moreTriggerCharacter": []
            },
            "documentRangeFormattingProvider": true,
            "documentSymbolProvider": true,
            "executeCommandProvider": {
                "commands": [
                    "clangd.applyFix",
                    "clangd.applyTweak"
                ]
            },
            "foldingRangeProvider": true,
            "hoverProvider": true,
            "implementationProvider": true,
            "inlayHintProvider": true,
            "memoryUsageProvider": true,
            "referencesProvider": true,
            "renameProvider": true,
            "selectionRangeProvider": true,
            "semanticTokensProvider": {
                "full": {
                    "delta": true
                },
                "legend": {
                    "tokenModifiers": [
                        "declaration",
                        "definition",
                        "deprecated",
                        "deduced",
                        "readonly",
                        "static",
                        "abstract",
                        "virtual",
                        "dependentName",
                        "defaultLibrary",
                        "usedAsMutableReference",
                        "usedAsMutablePointer",
                        "constructorOrDestructor",
                        "userDefined",
                        "functionScope",
                        "classScope",
                        "fileScope",
                        "globalScope"
                    ],
                    "tokenTypes": [
                        "variable",
                        "variable",
                        "parameter",
                        "function",
                        "method",
                        "function",
                        "property",
                        "variable",
                        "class",
                        "interface",
                        "enum",
                        "enumMember",
                        "type",
                        "type",
                        "unknown",
                        "namespace",
                        "typeParameter",
                        "concept",
                        "type",
                        "macro",
                        "modifier",
                        "operator",
                        "comment"
                    ]
                },
                "range": false
            },
            "signatureHelpProvider": {
                "triggerCharacters": [
                    "(",
                    ")",
                    "{",
                    "}",
                    "<",
                    ">",
                    ","
                ]
            },
            "standardTypeHierarchyProvider": true,
            "textDocumentSync": {
                "change": 2,
                "openClose": true,
                "save": true
            },
            "typeDefinitionProvider": true,
            "typeHierarchyProvider": true,
            "workspaceSymbolProvider": true
        },
        "serverInfo": {
            "name": "clangd",
            "version": "clangd version 16.0.6 unix x86_64-portbld-freebsd14.2"
        }
    }
}

A couple of expected uninteresting messages gets sent before the channel gets idle.

initialized, ale → clangd
{
    "method": "initialized",
    "jsonrpc": "2.0",
    "params": {}
}
testDocument/didOpen, ale → clangd
{
    "method": "textDocument/didOpen",
    "jsonrpc": "2.0",
    "params": {
        "textDocument": {
            "uri": "file:///tmp/hello/hello.cpp",
            "version": 2,
            "languageId": "cpp",
            "text": "#include \"greet.hpp\"\n\nint main() {\n    say_it();\n    return 0;\n}\n"
        }
    }
}

Use existing command, with disappointment

Navigate to say_it() and call ALEGoToDefinition. Here everyone probably expects to end up on its definition in greet.cpp, but the way clangd works is that where you end up depends on its internal state. For the first few minutes, even on a trivial project like this one, you end up at the declaration in greet.hpp. Do you see why this debugging session happened yet‽

id 5: textDocument/definition, ale → clangd
{
    "method": "textDocument/definition",
    "jsonrpc": "2.0",
    "id": 5,
    "params": {
        "textDocument": {
            "uri": "file:///tmp/hello/hello.cpp"
        },
        "position": {
            "character": 4,
            "line": 3
        }
    }
}
id 5: response, ale ← clangd
{
    "id": 5,
    "jsonrpc": "2.0",
    "result": [
        {
            "range": {
                "end": {
                    "character": 11,
                    "line": 2
                },
                "start": {
                    "character": 5,
                    "line": 2
                }
            },
            "uri": "file:///tmp/hello/greet.hpp"
        }
    ]
}
textDocument/didOpen, ale → clangd
{
    "method": "textDocument/didOpen",
    "jsonrpc": "2.0",
    "params": {
        "textDocument": {
            "uri": "file:///tmp/hello/greet.hpp",
            "version": 3,
            "languageId": "cpp",
            "text": "#pragma once\n\nvoid say_it();\n"
        }
    }
}

Attempt new command, ends in failure

Now comes our actual command. I've tried to make the request look like to Protocol Extension describes it, but another pair of eyes might spot something I'm missing.

id 7: textDocument/switchSourceHeader, ale → clangd
{
    "method": "textDocument/switchSourceHeader",
    "jsonrpc": "2.0",
    "id": 7,
    "params": {
        "TextDocumentIdentifier": "file:///tmp/hello/greet.hpp"
    }
}

To me, the response reads as that clangd understands the command but has issues with the argument.

id 7: response, ale ← clangd
{
    "error": {
        "code": -32602,
        "message": "failed to decode textDocument/switchSourceHeader request: missing value at (root).uri"
    },
    "id": 7,
    "jsonrpc": "2.0"
}

Ending comment

When reaching this point of debugging, clangd had caught up with my project and started to place me at the definition rather than the declaration. Which means I kind of lost interest, but I post this here just in case it'll help either myself or someone else in the future.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions