svaante / dape

Debug Adapter Protocol for Emacs
GNU General Public License v3.0
477 stars 31 forks source link

Getting dape working with JDTLS + Java Debug Server #19

Closed MagielBruntink closed 11 months ago

MagielBruntink commented 11 months ago

VS Code has a Java Debug Server extension [1], built on JDTLS, that uses DAP. Would it be feasible to configure dape such that it can be used to debug Java programs using that same Debug Server? It seems dap-mode and nvim-dap have managed to create working configurations. Those are very elaborate and tend to be mixed with all kinds of other functionality.

I'm having trouble getting started with configuring dape for this case however. Are there any pointers?

[1] https://github.com/microsoft/java-debug

MagielBruntink commented 11 months ago

Documentation on how to get this going is very sparse. This is what the Java Debug Server project provides: https://github.com/microsoft/java-debug#usage-with-eclipsejdtls

This is my current non-functional attempt based on that and some inspection of lsp-java.el and dap-java.el:

(when (package-installed-p 'dape)
  (require 'dape)
  (add-to-list 'dape-configs
               `(jdtls
         modes (java-ts-mode java-mode)
         command "jdtls"
         command-args ()
         :type "java"
         :request "launch"
         :command "vscode.java.startDebugSession"
                 :cwd dape-cwd-fn
                 :program dape-find-file-buffer-default
         :initializationOptions (:bundles "c:/Tools/Java/java-debug/com.microsoft.java.debug.plugin/target/com.microsoft.java.debug.plugin-0.50.0.jar"))))

The Java Debug Server has been built in c:/Tools/Java/java-debug without issue and the necessary plugin jar is present at that path.

Running M-x dape on a buffer in java-ts-mode then results in dape starting but with errors: *dape-debug*

[info] Starting new single session
[info] Process started ("jdtls")
[io] Sending:
(:arguments (:clientID "dape" :adapterID "java" :pathFormat "path" :linesStartAt1 t :columnsStartAt1 t :supportsRunInTerminalRequest t :supportsProgressReporting t :supportsStartDebuggingRequest t) :type "request" :command "initialize" :seq 1)
[error] Timeout for reached for seq 1

*dape-processes*

WARNING: Using incubator modules: jdk.incubator.foreign, jdk.incubator.vector
nov. 12, 2023 9:36:38 P.M. org.apache.aries.spifly.BaseActivator log
INFO: Registered provider ch.qos.logback.classic.servlet.LogbackServletContainerInitializer of service jakarta.servlet.ServletContainerInitializer in bundle ch.qos.logback.classic
nov. 12, 2023 9:36:38 P.M. org.apache.aries.spifly.BaseActivator log
INFO: Registered provider ch.qos.logback.classic.spi.LogbackServiceProvider of service org.slf4j.spi.SLF4JServiceProvider in bundle ch.qos.logback.classic
nov. 12, 2023 9:36:39 P.M. org.eclipse.lsp4j.jsonrpc.json.StreamMessageProducer fireError
SEVERE: Unable to identify the input message.
com.google.gson.JsonParseException: Unable to identify the input message.
    at org.eclipse.lsp4j.jsonrpc.json.adapters.MessageTypeAdapter.createMessage(MessageTypeAdapter.java:403)
    at org.eclipse.lsp4j.jsonrpc.json.adapters.MessageTypeAdapter.read(MessageTypeAdapter.java:139)
    at org.eclipse.lsp4j.jsonrpc.json.adapters.MessageTypeAdapter.read(MessageTypeAdapter.java:56)
    at com.google.gson.Gson.fromJson(Gson.java:1227)
    at com.google.gson.Gson.fromJson(Gson.java:1186)
    at org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler.parseMessage(MessageJsonHandler.java:119)
    at org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler.parseMessage(MessageJsonHandler.java:114)
    at org.eclipse.lsp4j.jsonrpc.json.StreamMessageProducer.handleMessage(StreamMessageProducer.java:193)
    at org.eclipse.lsp4j.jsonrpc.json.StreamMessageProducer.listen(StreamMessageProducer.java:94)
    at org.eclipse.lsp4j.jsonrpc.json.ConcurrentMessageProcessor.run(ConcurrentMessageProcessor.java:113)
    at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
    at java.base/java.lang.Thread.run(Thread.java:833)

Apparently the message sent to JDTLS by Dape is not as expected. I have no clue how to proceed from here.

PS. I have this JDTLS working perfectly fine with Eglot for normal LSP functionality.

ThangaAyyanar commented 11 months ago

After reading through the https://github.com/microsoft/java-debug#usage-with-eclipsejdtls , It says an [LSP client](https://langserver.org/) has to launch jdt.ls with initializationOptions so instead of passing the bundle option from the dape i passed it from eglot. I use the following package for java https://github.com/yveszoundi/eglot-java in this i modified the initialization options

2023-11-13_23-11-1699899557

and then i have created a custom function called eglot-dap-activate which will send the start debugger command

2023-11-13_23-11-1699899575

When i execute the above function, I got the following response

2023-11-13_23-11-1699899591

Based on the docs The response to this request will contain a port number on which the debug adapter is listening, and to which a client implementing the debug-adapter protocol can connect to. so we can connect to this port from dape i guess ?? which i haven't tried it yet.

let's hack more on this and make it work for java @MagielBruntink :muscle:

MagielBruntink commented 11 months ago

Yes, I now also realize that we need eglot or another LSP client to first send this special initializationOptions message to an instance of JDTLS, which will then be able to receive the Command to "vscode.java.startDebugSession". Then it will respond with a port on which dape can connect.

Rather tricky process...

svaante commented 11 months ago

I just started looking into it. It indeed seams cumbersome. I am willing to add to dape-config format to allow for this strange startup pattern to work, maybe som before hook or something. I think it would be preferable to have thedape function be the start debugging function for all languages. Maybe initwhich is executed with current dape-config before creating the connection to the server or somthing...

MagielBruntink commented 11 months ago

That's great news! I think with a few internals of eglot we can do this. Let's try to create a minimal init sequence just using eglot by looking at how eglot-java.el pulls it off.

svaante commented 11 months ago

That's great news! I think with a few internals of eglot we can do this. Let's try to create a minimal init sequence just using eglot by looking at how eglot-java.el pulls it off.

Sounds like a good place to start. If anybody got a nice a concise example please share.

MagielBruntink commented 11 months ago

Here is some lisp to generate a working dape config for JDTLS Debug Server with the help of eglot. This is rudimentary and probably does not work with multiple main classes, missing project structure, and several edge cases.

In the following it is assumed eglot is already running on the Java project with the following eglot config applied (replace the jar file path with your own):

(add-to-list 'eglot-server-programs
         '((java-mode java-ts-mode) .
           ("jdtls"
        :initializationOptions
        (:bundles ["c:/Tools/Java/java-debug/com.microsoft.java.debug.plugin/target/com.microsoft.java.debug.plugin-0.50.0.jar"]))
           ))

Eval the following functions and then run M-: (add-to-list 'dape-configs (get-dape-jdtls-debug-config (current-buffer))) while on a .java file in a properly defined project.

(defun get-dape-jdtls-debug-config (java-file-buffer)
  (with-current-buffer java-file-buffer
    (let* ((project (project-name (project-current)))
       (server (eglot-current-server))
       (main (plist-get (elt (eglot-execute-command server "vscode.java.resolveMainClass" project) 0) :mainClass))
       (classpaths (elt (eglot-execute-command server "vscode.java.resolveClasspath" (vector main project)) 1))
       (port (eglot-execute-command server "vscode.java.startDebugSession" nil)))

      `(jdtls
    modes (java-ts-mode java-mode)
    host "localhost"
    port ,port
    :args ""
    :type "java"
    :request "launch"
    :host "localhost"
    :cwd dape-cwd-fn
    :projectName ,project
    :mainClass ,main
    :stopOnEntry t
    :console "dape"
    :modulePaths [""]
        :classPaths ,classpaths))))

Then running M-x dape should work. Just run jdtls as the adapter when asked and clean some of the pre-filled parameters that dape puts there.

MagielBruntink commented 11 months ago

@svaante the above would work with a user defined init function that gets the config template for the mode, and the current buffer, as parameters. The function would then be able to 'enrich' the config further with language specifics and dynamic values (such as the classpaths , mainClass, etc like above)

svaante commented 11 months ago

Thanks @MagielBruntink and @ThangaAyyanar!

I am not sure about the dape-config format, went with fn instead of init.

Used the following configuration based on @MagielBruntink

(require 'eglot)
(setq dape--jdtls-java-debug-bundle "path/to/java-debug/com.microsoft.java.debug.plugin/target/com.microsoft.java.debug.plugin-0.50.0.jar")
(add-to-list 'eglot-server-programs
         `((java-mode java-ts-mode) .
           ("jdtls"
           :initializationOptions
            (:bundles [,dape--jdtls-java-debug-bundle]))))

(add-to-list 'dape-configs
            `(jdtls
              modes (java-mode java-ts-mode)
              fn (lambda (config)
                   (let* ((default-directory (project-root (project-current)))
                          (project (project-name (project-current)))
                      (server (eglot-current-server))
                      (main (plist-get (elt (eglot-execute-command server "vscode.java.resolveMainClass" project) 0) :mainClass))
                      (classpaths (elt (eglot-execute-command server "vscode.java.resolveClasspath" (vector main project)) 1))
                      (port (eglot-execute-command server "vscode.java.startDebugSession" nil)))
                     (append (list
                              'port port
                              :mainClass main
                              :projectName project
                              :classPaths classpaths)
                             config)))
              :args ""
              :type "java"
              :request "launch"
              :cwd dape-cwd-fn
              :stopOnEntry t
              :console "dape" ;; Don't know whats going on here
              :modulePaths [""]))

Please get back to me if bf91567e5e81b15c5179609cb520ce8d3318ed54 works or if something can be improved.

MagielBruntink commented 11 months ago

Working great, thanks @svaante ! I'll see if the configuration could be further optimized (console eg. is set also in dap-java.el).

Then next on the list is the Java Unit Tests runner/debugger support :-) https://github.com/microsoft/vscode-java-test

MagielBruntink commented 11 months ago

Also: multiple main classes in nested Maven projects are a problem indeed. We could allow the user to pick one using completing read, or always select the file/class that the user opened dape on (sometimes there will be no main method).

MagielBruntink commented 11 months ago

@svaante One struggle is that commands like dape-restart are sometimes run on a different buffer than where dape was initially started, for example if point is within the dape REPL or info buffers.

For most debugger that's not an issue, but if we rely on eglot like we do for jdtls interaction, it is. On normal start we are in language mode buffer, which tells dape what config to use and allows us to fetch the relevant eglot server with (eglot-current-server).

However activating dape-restart by doing r in the REPL or clicking restart in the info buffer with point not in the (java) language buffer causes the issue. Since it's not in a language buffer (eglot-current-server) will return nil and break the rest of the config fn for jdtls.

A fix is perhaps to save the file for which dape was initially launched and expose that to the config fn?

MagielBruntink commented 11 months ago

I implemented a tentative fix for the above in the following. I store the launch-file among the config and use it upon restarts to do the init correctly. Let me know if you find that a clean solution.

My dape-jdtls code is growing rapidly and will probably further expand with convenience functions for the tests run/debug functionality. Perhaps you are interested in a PR for extensions/dape-jtdls.el ? Or otherwise I should create a separate repo/package for this?

There is also some new code that asks the user to select the main class (with completion) :-)

(defun dape-jdtls-config-fn (config)
  (with-current-buffer
      (if (plist-get config 'launch-file)
      (find-file (plist-get config 'launch-file))
    (current-buffer))
    (let* (
     (default-directory (project-root (project-current)))
         (server (eglot-current-server))
         (entryPoints (eglot-execute-command server "vscode.java.resolveMainClass" (project-name (project-current))))
     (selectedEntryPoint (dape-jdtls-select-entry-point entryPoints config))
     (projectName (plist-get selectedEntryPoint :projectName))
     (mainClass (plist-get selectedEntryPoint :mainClass))
     (classpaths (elt (eglot-execute-command server "vscode.java.resolveClasspath" (vector mainClass projectName)) 1))
     (port (eglot-execute-command server "vscode.java.startDebugSession" nil)))
    (append (list
         'port port
         'launch-file (buffer-file-name (current-buffer))
             :mainClass mainClass
         :projectName projectName
         :classPaths classpaths)
        config))))

(defun dape-jdtls-select-entry-point (entryPoints config)
  (let* ((separator "/")
     (candidates (mapcar (lambda (entryPoint)
                   (s-concat (plist-get entryPoint :projectName) separator
                                         (plist-get entryPoint :mainClass)))
                 (append entryPoints '())))
     (user-input (s-concat (plist-get config :projectName) separator
                   (plist-get config :mainClass)))
     (selected (completing-read "Select a main class: " candidates nil t
                    (unless (s-equals? user-input separator) user-input)))
     (selected-split (s-split separator selected)))
    (list :projectName (nth 0 selected-split)
      :mainClass (nth 1 selected-split))))