Building an IntelliJ plugin for debugging purpose can offer valuable insights into the data being handled, allowing you present it more effectively and upgrade the developer experience. However, the limited documentation around IntelliJ’s APIs often makes plugin development challenging. In this guide, we’ll walk through the steps to create a plugin that can execute methods on objects available in the stack when the debugger is paused.
1. Plugin creation
First, you will have to create an empty plugin. The easiest way to do it is directly inside IntelliJ:
- Open the New Project wizard by selecting File | New | Project… and fill in the required details:
- From the menu on the left, choose the IDE Plugin project type.
Sources:
https://plugins.jetbrains.com/docs/intellij/creating-plugin-project.html
2. Create an action
The goal of this demo plugin will be to give to the user the power to execute a predefined method on a variable when the debugger is paused.
Adding a new button in IntelliJ UI is the first step in designing the plugin behavior. It will create an entrypoint for the user to execute the plugin code.
The system designed for that in IntelliJ APIs is called an Action
.
package be.bruyere.romain.tutointellijplugindebug
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAware
class CustomAction : AnAction(), DumbAware {
override fun actionPerformed(event: AnActionEvent) {
// TODO add action code
}
override fun update(event: AnActionEvent) {
event.presentation.isEnabledAndVisible = true
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
}
With a custom action like this one, you can:
- Provide some code to be executed by the plugin, with
actionPerformed
method - Add logic if you want the action to be shown conditionnaly, with
update
method - Specify which thread will execute the
update
method
For the plugin button to be shown to the user, you will have to define its anchor in plugin.xml
file, located in the /resources/META-INF folder
.
<actions>
<action id="be.bruyere.romain.tutointellijplugindebug.CustomAction"
class="be.bruyere.romain.tutointellijplugindebug.CustomAction" text="My custom action" description="My custom action">
<add-to-group group-id="XDebugger.ValueGroup" anchor="first"/>
</action>
</actions>
Here, the plugin button is placed as the first action in the right-click debugger menu. By running the Grable task intellij.runPlugin
, you can now try to use it.
Sources:
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html
3. Add a method invocation in debug mode
To execute, on the fly, a method on an object when the debugger is paused, multiple steps are required:
- Get the object reference
- Get the thread in which the method will be invoked
- Get the method reference
- Execute the method and get its return value
Prerequisites
Before these steps, you need to enable the debugger features in your plugin. To do this, you’ll need to add a dependency on the java
plugin in the build.gradle.kts
file.
intellij {
version.set("2024.1")
type.set("IC")
plugins.set(listOf("java")) // To add
}
3.1 Get the object reference
Jetbrains APIs provide a simple way to get the selected node from the right-click event context menu. The object reference value can then be accessed from there.
private fun getObjectReference(event: AnActionEvent): ObjectReferenceImpl? {
val node = XDebuggerTreeActionBase.getSelectedNode(event.dataContext)
val valueContainer = node?.valueContainer
val value = valueContainer as? JavaValue
return value?.descriptor?.value as ObjectReferenceImpl?
}
3.2 Get the thread in which the method will be invoked
When the debugger is paused, invoking a method on the fly must be done in a specific thread, called the manager thread. The manager thread manages tasks and operations related to the debugger, ensuring that certain operations are run on the correct thread during debugging sessions
private fun getManagerThread(event: AnActionEvent): DebuggerManagerThreadImpl? {
val node = XDebuggerTreeActionBase.getSelectedNode(event.dataContext)
val valueContainer = node?.valueContainer
val value = valueContainer as? JavaValue
return value?.evaluationContext?.managerThread
}
As for the object reference, the manager thread can be accessed from the selected node.
3.3 Get the method reference
With the object reference, any method can be accessed by its name.
// Example: getMethod(myObjectReference, "toString")
private fun getMethod(targetObjectRef: ObjectReferenceImpl, methodName: String): Method? =
targetObjectRef.referenceType().methodsByName(methodName)[0]
3.4 Execute the method and get its return value
Using the method and its associated object reference, you can construct a command that encapsulates the method call. You can then execute this command on the manager thread, wait for the response and return the result.
private fun executeMethod(
event: AnActionEvent,
targetObjectRef: ObjectReferenceImpl,
managerThread: DebuggerManagerThreadImpl,
method: Method?
): Value? {
val session = DebuggerManagerEx.getInstanceEx(event.project).context.debuggerSession
val context = session?.process?.suspendManager?.pausedContext
val command = CustomDebuggerCommandImpl(context, targetObjectRef, method)
managerThread.invokeAndWait(command)
return command.result
}
The command CustomDebuggerCommandImpl
is not built in Jetbrains APIs. It’s a concrete implementation of the abstract class DebuggerCommandImpl
, representing a specific command that executes within the context of IntelliJ’s debugging system. You’ll have to add that class by yourself in the your plugin.
class CustomDebuggerCommandImpl(
private val context: SuspendContextImpl?,
private val objectReference: ObjectReferenceImpl,
private val method: Method?
) : DebuggerCommandImpl() {
var result: Value? = null
private set
override fun action() {
val threadReference = context?.frameProxy?.threadProxy()?.threadReference
result = objectReference.invokeMethod(
threadReference,
method,
listOf(),
ObjectReferenceImpl.INVOKE_SINGLE_THREADED
)
}
}
Sources:
https://docs.oracle.com/en/java/javase/13/docs/api/jdk.jdi/module-summary.html
https://github.com/JetBrains/intellij-community/tree/master
4. Use of the result
The result obtained from the method invocation is not a basic object type like String
, Integer
, or any other standard object. Instead, it is a reference to an object created within the debugging context.
For example, if the invoked method was toString
, you would receive a StringReferenceImpl
. You can then retrieve the string value from that object.
val result = executeMethod(event, objectReference, managerThread, method)
val stringValue = result as? StringReferenceImpl
if(stringValue != null) {
println(stringValue.value())
}
There are various other implementations of type references, such as IntegerValueImpl
or, more generally, ObjectReferenceImpl
for custom classes.
Sources:
https://github.com/JetBrains/intellij-deps-jdi/tree/master/src/main/java/com/jetbrains/jdi
5. Full code example
Now that you understand the details of this IntelliJ plugin development process, here is a complete working example of the code: https://github.com/rombru/tuto-intellij-plugin-debug
If you'd like to see how this process was applied to create a published plugin, check out this plugin I developed for visualizing geometries on a map in debug mode:
https://plugins.jetbrains.com/plugin/25275-geometry-viewer
https://github.com/rombru/geometry-viewer