DC-Grails-VS
Pre-release. The debugger works: breakpoints bind in .groovy files, closures
included, and scripted sessions have been driven end to end against Grails 7.2.3
and 8.0.0. The main flows have also been seen working in a real VS Code window
(see Requirements for what has not). But it has been exercised on two
applications, by one person. Expect to find things. Known gaps are listed at the
bottom; the issue tracker is
on GitHub.
Version 0.1.31 is a pre-release. To install it, open the Marketplace page above
and choose Switch to Pre-Release Version, or from the command line (the
--pre-release is needed while this is one):
code --install-extension gaolei8888.grails-gradle-extension --pre-release
Grails and Gradle commands from the command palette and a sidebar tree — and a
debugger that can actually stop on a line of Groovy.
Breakpoints in .groovy files
VSCode does not bind breakpoints in Groovy. The Java debugger resolves "file →
class name" through JDT, and JDT does not index .groovy, so the breakpoint is
never registered with the JVM and the dot stays hollow. No setting changes this.
This extension ships its own debug adapter. In attach mode it does not parse
Groovy at all: it asks the running JVM which class owns a line, which is the only
authoritative answer there is. Closures come out for free — a synthetic closure
class reports the same source file as the class that encloses it — and classes
that are not loaded yet are picked up when they load.
What that means in practice:
- Breakpoints bind in controllers, services, domain classes and inside closures,
including nested ones.
- They bind in methods rewritten by Grails' AST transforms — a
@Transactional
method whose body has been moved into $tt__… still stops on the right line.
- A breakpoint on a line with no bytecode (a method signature, a blank line) slides
down to the first line that can hold one, and says so.
- Variables captured by a closure are shown as their values, not as the
groovy.lang.Reference the compiler boxed them in.
- A Grails scope beside Locals whenever the thread is serving a request, with
params, request, response and session. None of those is a local or a
field of anything on the stack — they belong to the request, which Spring keeps
in a thread local — so nothing else shows them.
- Maps show their entries.
params reads as controller, action, id, not as
a wrapper around a table and a modCount.
- A domain object shows its own properties. GORM's traits compile a field into
every domain class they touch, and a class with one property of its own arrives
carrying half a dozen of them; those are hidden, and
id and version are not.
- Step over lands on the next line of your code, including on the way back out of
a call. It is built out of breakpoints rather than JDI stepping, which in a
Grails application would sometimes run to the end of the method instead.
- Step into enters a
@Transactional method, and enters a closure: stepping
into (1..3).each { i -> lands in the closure body. Step out walks back through
the wrapper frames Grails generates, which have no lines to stop on.
- Stepping skips the framework: the Groovy runtime, reflection, the transaction
template and the servlet container are stepped over rather than into.
- Exception breakpoints, caught and uncaught, filtered the same way: it stops
where your code threw, not in the framework. A Grails request throws and catches
its way through startup and dispatch, and stopping for all of it would be
unusable.
Exception breakpoints are in the Breakpoints section of the Run and Debug
view. The exception's message is read from the object rather than by calling
getMessage(), so nothing runs in the application to describe a stop.
Run Grails: Debug App. It starts gradlew bootRun --debug-jvm, waits for the
JVM to report that it is listening, and attaches.
For an application this extension did not start — a war on a server, one started
from a terminal, one in a container with the agent on — Grails: Attach to
Running App asks for a host and port and attaches to it. Or write your own
attach configuration:
{
"type": "groovy-dap",
"request": "attach",
"name": "Attach to Grails (Groovy)",
"hostName": "localhost",
"port": 5005
}
The type is groovy-dap, not groovy: another Groovy extension may already own
groovy, and VS Code allows one debug adapter per type. A launch.json written for
0.1.29 or earlier with "type": "groovy" keeps working as a compatibility alias, as
long as no other extension owns groovy (the "Grails - Debug" output channel says so
when one does); change it to groovy-dap when convenient, and you must on a machine
with Code Groovy. If you have Code Groovy installed, its own "Groovy / Grails..."
entry in the Run and Debug dropdown is Code Groovy's, not this extension's.
Launch configuration
Starting the debugger from Run and Debug runs ./gradlew bootRun --debug-jvm and
attaches; F5 with no launch.json in a Grails workspace is meant to do the same.
It attaches instead of starting a second app if one is already listening, and
ending the session stops an app it started. Launch attributes: projectDir,
args, env, startTimeout, sourcePaths. The debug port is not configurable,
because Gradle hard-codes 5005.
Checked in a real VS Code window (2026-10-12, Grails 8.0.0, JDK 21): the Run and
Debug button opens "Select debugger"; picking Grails (Groovy debugger) (the
debugger's label) starts the app and attaches within about 10 seconds; a request
stops at the breakpoint with the right values; Continue resumes; Stop in the status
bar frees the ports. On later runs the button starts it directly. Starting it again
while a debugger is attached shows a warning and does not start a second app. The
name Grails: Debug App is the configuration's name: it appears in a generated
launch.json, under Add Configuration, and in the "Select Launch Configuration"
list (one entry, for the workspace folder; builds of 0.1.7 before the fix also
listed a duplicate under "user settings").
Not yet verified: F5, and the F10/F11 keys.
Breakpoints in .gsp files
GSP files include syntax highlighting for HTML, namespaced Grails tags (including
custom tag libraries), ${…} expressions, directives, scriptlets and GSP
comments. JavaScript and CSS inside script/style blocks keep their highlighting.
Toggle Block Comment uses %{-- … --}%.
Set one in the gutter of a GSP and it binds, stops on the page's own line, and
shows the .gsp in the call stack. The Grails scope, the variables pane and
conditions all work there.
There is no SMAP behind this and no JSR-45: a GSP compiles into a class named
after the page's path, reporting no source file and no second stratum. The only
mapping that exists while the application runs is an array of line numbers held
by Grails, read as a field — so nothing runs in your application to place a
breakpoint.
A page is compiled the first time it renders, so a breakpoint set before that
stays hollow and says so; render the page once and it binds. A line that
generates no code — a closing tag, blank markup — says that instead.
One line of markup becomes several statements in several classes, and the
breakpoint goes on the first of them, so a line stops once per pass rather than
two or three times. A line inside a <g:each> stops once per iteration.
Stepping works inside a page: step over moves a line of markup at a time, and
step into enters a tag body.
Grails 7 compiles pages at runtime, in a packaged war as much as in a
development run, so there is no precompiled case to handle. A page compiled from
inside a war is a differently named class, because the name comes from the path;
the part below views is the same either way and that is what a breakpoint
matches on, so debugging a deployed application works from your copy of the
file.
Debugging tests
Grails: Debug Tests runs gradlew test --debug-jvm and attaches to the test
JVM, which is the same machinery the application uses — Gradle forks it with the
same agent on the same port and announces it on the same stream. It offers the
spec in the active editor as the filter, so debugging the failure you are looking
at is one command; leave the box empty to debug the whole run.
Commands
Every Grails command and Gradle task this project knows about is registered — 205
of them. The ones people reach for are in the Grails view in the activity bar,
under Run, Create (empty), Scaffold from domain class, Database Migration and
Gradle; the rest are in the command palette under Grails: and Gradle:.
Create and Scaffold are not the same thing. create-controller writes an empty
controller; generate-controller scaffolds one, actions and all, from a domain
class that already exists — which is why every entry under Scaffold asks for one.
Run, Debug and Stop are also in the status bar (shown only in a Grails workspace:
a top-level grails-app folder, or a gradle.properties / build.gradle /
build.gradle.kts that mentions "grails"; a Grails app in a subfolder is not
detected). While a build is
running the pair is replaced by a spinner that turns into a check and the address
once the application reports that it is serving; clicking it opens the app, or
shows the build output while it is still starting.
The application's log goes to an output channel — Grails - Normal for Run App,
Grails - Debug for Debug App — reachable any time with Grails: Show Output.
Requirements
- VS Code 1.90 or newer.
- A Grails project with a Gradle wrapper (
gradlew) in the folder you open.
- Grails 7.x or 8.x. Checked against 7.2.3 and 8.0.0 by scripted debug sessions.
In a real VS Code window (2026-10-11) the status bar buttons, Debug App,
breakpoint hits, Locals and Watch values, evaluating a variable path in Watch,
changing a value, and breakpoint rebinding after a class reload were seen working.
The launch configuration, stepping with the toolbar buttons and the JDK search were
seen working on 2026-10-12 (see Launch configuration). Not yet seen in a real
window: F5, the F10/F11 keys, hover, and Windows and Linux paths. A Grails 8
application itself needs Java 21 or newer.
- A full JDK, 17 or newer (not a JRE: the adapter uses the
jdk.jdi module).
The JDK your application runs on can be a different one.
- JDK discovery. The debug adapter's JDK is searched in this order, and the
first usable one wins:
grails.debug.javaPath, JAVA_HOME from grails.run.env
or the environment, macOS java_home, SDKMAN, the usual install folders
(Homebrew, Linux, Windows), then java on PATH (the macOS /usr/bin/java stub
is skipped). A usable JDK is Java 17 or newer with the jdk.jdi module. For the
application's build (gradlew) the only check is that JAVA_HOME's bin/java
is executable: a valid JAVA_HOME of any version is left alone, and the search
only fills one in when there is none. The single error naming the ways out
appears only when there is no usable java at all (none found and none on
PATH, or only the macOS stub). A java that is on PATH but too old or
without jdk.jdi is still handed to the adapter, which then fails to start with
Java's own message; point grails.debug.javaPath at a full JDK 17+. VS Code
usually resolves your shell environment itself, so JAVA_HOME is normally there;
this search is a fallback for when it is not (seen once, cause unknown). It was
verified with VS Code started with an empty environment (no JAVA_HOME, a bare
PATH): the app started and attached using the SDKMAN JDK. You can also set
grails.run.env ({"JAVA_HOME": "..."}) or grails.debug.javaPath, or start VS
Code from a terminal with code ..
- For
grails.debug.adapter: "java" only: the
Debugger for Java
extension.
Settings
| Setting |
Default |
What it does |
grails.run.command |
"" |
Command line to start the app instead of the Gradle wrapper. For projects that boot through a script which exports the environment the app needs. |
grails.run.debugCommand |
"" |
Same, for Debug App. Defaults to grails.run.command with --debug-jvm appended. |
grails.run.env |
{} |
Environment variables to add for the app process. |
grails.run.args |
[] |
Extra arguments for bootRun. |
grails.debug.adapter |
groovy |
groovy uses the adapter in this extension. java hands the session to vscode-java-debug, which cannot bind a breakpoint in a .groovy file. |
grails.debug.javaPath |
"" |
The java used to run the debug adapter. Defaults to the discovery order above. |
Attach configurations also take sourcePaths (roots for resolving stack frames,
defaulted from the project layout), stepFilters (packages to step over),
stepIntoProjectCodeOnly and trace.
Stopping
Stop reaches the application, not just the build. Gradle forks the app from its
daemon, and the daemon is reused between builds, so the app is only sometimes a
descendant of the process this extension started — killing that process tree
leaves the app running about as often as not, holding the debug port and 8080.
Stop kills the tree and, separately, whatever JVM is listening on the ports the
app announced.
Debug App also checks the debug port before starting. If something already holds
it, it says what, and offers to stop it — because a JVM that cannot open its debug
port produces a build that stops after :findMainClass and prints nothing at all,
which is indistinguishable from a hang.
Hover and watch
Hovering a name shows its value, and watch expressions work, for anything that is
a path: user.name, params['id'], items[0].label, this.someService. Names
from the Grails scope resolve too, so params and request work in a hover even
though neither appears in the source.
Read, not run. A method call, an operator or a closure needs an expression
compiled and executed inside the application, which means resuming its threads to
do it -- so those are refused, by name and with the reason, rather than reported
as missing.
Logpoints and hit counts
Right-click in the gutter for Add Logpoint: a breakpoint that prints and
carries on rather than stopping. Anything in braces is read the way a hover is, so
iteration {i}, so far {doubled}, from {params.id}
prints iteration 2, so far [105, 106], from 47. Lists and maps are written out
rather than shown as an object id — up to eight entries, two levels deep. An
expression it cannot read prints the reason in place of the value instead of
disappearing.
Hit Count takes a number, optionally after an operator:
|
|
100 |
stop from the hundredth hit onward (same as >=100) |
=100 |
stop on the hundredth hit only |
>100 <100 <=100 |
as they read |
%10 |
stop on every tenth hit |
The count is of hits that would have stopped, so the second copy Groovy compiles
of every line does not advance it. A hit count that cannot be read is reported on
the breakpoint rather than ignored.
Conditions
The Condition box takes a comparison:
params.id == '5' i > 3 user.name != null w.count <= limit
Each side is a path or a literal — the same reading a hover does — so names from
the Grails scope work here as well: params.action == 'index' is a condition
although params is not a local of anything on the stack.
== compares by value, the way Groovy's does. Anything that needs an expression
compiled and run inside the application — a call, arithmetic, a closure — is
reported on the breakpoint, and that breakpoint then stops every time. A
condition quietly treated as false would be a breakpoint that never fires, which
reads as a broken debugger rather than as a condition to rewrite.
With a hit count as well, the count advances only when the condition was true:
i > 1 with =2 stops the second time i > 1 holds.
Data breakpoints
Right-click a field in the variables pane and choose Break on Value Change.
The debugger stops where the write happens and says what the value was and what
it is about to become:
dapspike.SpikeService.touches: 0 -> 1
Watching a field is a JVM facility, so nothing runs in your application to arm
one. It watches the object you pointed at rather than every instance of the
class, which is what "this variable" means in the pane. Reads can be watched too
where the JVM allows it.
Only fields can be watched — that is all the JVM reports writes to — so the
option does not appear for a local, an array element or a map entry, and a final
field is refused because it is never written again.
Changing a value
A local, a field, an array element or an existing map entry can be changed from
the variables pane while the target is stopped: numbers, strings, booleans and
null. Setting seed from 4 to 7 at the top of a service method changes what the
application then returns.
A local that a closure captured is written inside the groovy.lang.Reference it
is boxed in, not over it — replacing the box would leave the closure holding the
old one. A map entry is changed in place; a key that is not there cannot be added
this way.
This is the one place the debugger runs code in your application. JDI will not put
an int into a slot typed java.lang.Integer, and Groovy types nearly everything
that way, so making a boxed number calls valueOf on the box class — a JDK static
that takes no locks — single-threaded. Reading a value never runs anything.
Explain an exception
Requires VS Code 1.90 or later. When paused on a Groovy exception, click
Explain Exception in the status bar or run Grails: Explain Exception.
Choose one of the language models available in VS Code. The explanation streams
into a read-only document beside your code and includes the context used.
The request sends the exception type/message, up to twelve application frames,
and nearby source from up to three workspace files. It does not collect locals,
Grails request/session values or environment variables. Source and exception
messages can themselves contain sensitive text; Grails: Show Exception Context
lets you inspect the local snapshot before choosing to use a model. No data is
sent automatically when a breakpoint is hit. The snapshot uses current editor
text (including unsaved changes), which may differ from a deployed application.
Model access and any initial consent are handled by VS Code's
Language Model API.
No particular model or vendor is required. If no model is available, the command
opens the local exception context with setup guidance. Cancel the progress
notification to stop a request; continuing or stepping the debugger also cancels
it. Explanations suggest changes but do not edit files or execute application code.
Development and tests
Run pnpm install, npm run build:server, pnpm test, and pnpm lint.
The debug adapter has its own dependency-free checks: cd server && ./gradlew verifyProtocol (also run by ./gradlew build).
pnpm test:editor runs the real VS Code/Grails integration suite in an isolated
profile. See testing instructions for the standalone testbed,
environment overrides and test coverage.
Known issues
- Step over once skipped code. It was reported that after stepping over the last
line of a service method back into the controller, a further step over skipped
the remaining code. It could not be reproduced afterwards (several target
shapes, JDK 21 and 25, Grails 7 and 8), and the cause is unknown.
- Step into a
@Transactional method enters it (scripted runs on Grails 7.2.3
and 8.0.0, 2026-10-12). Inside a .each closure, Step Into stops in the closure
for the first iteration only and then continues past the loop; put a breakpoint in
the closure to stop on every iteration. Step Into relies on sourcePaths being the
project's source roots (the extension fills these in): with the application folder
itself as the only root, Step Into does not enter calls.
- Watch, hover, the Debug Console, logpoints and conditions read variable paths
only (
a.b.c, list[0], map['k']); nothing is run in the application, and
list.size() is refused by name, with the reason. General expressions are not
implemented.
- Simple variable values can be changed in the Variables panel: numbers,
true / false, characters, strings and null, written as literals. A number
put into a boxed or Object slot is made by running valueOf of one of the JDK
box classes in the application (the only code the debugger runs there). Not
supported: objects and collections. Editing from Watch (setExpression) is
deferred. Changing a value was seen working in a real VS Code window with the
earlier adapter; which request VS Code sends for it is not known.
- Hot reload needs a recompile. Saving a source file alone does not change the
running app. Once the class was reloaded (what triggers that was not
established), breakpoints rebound to the new class and showed the new value; seen
in one scenario in a real window.
- Step Into with nothing to enter does nothing. On the last line of an action
it stays on that line; Step Out or Step Over there lets the request finish
without stopping in framework code.
- Stop while paused: if the JVM is stopped at a breakpoint, Stop only ends the
Gradle client; the application and the adapter stay until you Continue.
- Stopping kills any
java process listening on the debug port (5005), which
Gradle hard-codes.
- Code Groovy's own classpath script may print
Cannot mutate the state of configuration ':compileOnly' in its output channel on Grails projects; it is
harmless and not from this extension.
- Two different applications serving the same view path in one JVM would make a
GSP breakpoint ambiguous; it binds to neither rather than guessing.
- The AI command's real exception capture and no-model behavior have been tested
in VS Code. Model streaming/error/cancellation paths have automated tests with
a simulated provider; a live provider response still needs verification.
Star it
If you use this, clone it, or build something of your own on top of it, please
star the project on GitHub. It takes
one click, and it is the main way anyone else finds out this exists.
License
MIT