In this part of the series, we add support for debugging EasyScript, our simplified subset of JavaScript, using Chrome DevTools.
Unlike the previous parts of the series, we won't have to make any changes to the language's grammar, or add any new language features - however, we'll have to heavily modify our existing implementation.
Debuggers require Nodes to be instrumented,
which means implementing the
InstrumentableNode
interface.
We do that in the
EasyScriptStmtNode
class.
To help with implementing the
createWrapper()
method,
we annotate the EasyScriptStmtNode
class with the
@GenerateWrapper
annotation
from the Truffle DSL.
We also return true
for the
StatementTag
standard tag
in the hasTag()
method,
which is how the debugger recognizes which Nodes represent statements.
Since all Nodes that provide one of the
StandardTags
must also provide
a SourceSection
,
we will add a field of that type to the
EasyScriptStmtNode
class,
and add a parameter for it to the class's constructor,
and use the field in the override of the
getSourceSection()
method
(this means we need to modify all subclasses of EasyScriptStmtNode
to pass a SourceSection
to its constructor).
While we want the debugger to be able to stop on almost all statements,
a few of them are special, and we want the debugger to skip past them.
One example is a function declaration -- there's little point in stepping into one
(a function invocation is a different story, but not a function declaration).
For that reason, we make sure to pass a null
SourceSection
to EasyScriptStmtNode
from the FuncDeclStmtNode
class,
and also override the
hasTag()
method
in it to always return false
(since Nodes providing standard tags must have a SourceSection
).
Similarly, class declarations are also not interesting,
for the same reasons as function declarations.
Since those don't have a dedicated statement, but instead use the
GlobalVarDeclStmtNode
class,
we will also override the
hasTag()
method
in it to check whether a given GlobalVarDeclStmtNode
has a non-null
SourceSection
,
and only provide the StatementTag
for it if it does.
We'll make sure to pass a null
SourceSection
to the GlobalVarDeclStmtNode
constructor when parsing a class declaration,
but we still want the debugger to stop on non-class global variable declarations,
since their initializers can be complex expressions that we might want to step through.
In order to correctly support functionality like Step Over and Step Into,
we need to mark function calls boundaries.
We do that by overriding the hasTag()
method
in the UserFuncBodyStmtNode
class
to return true
for the
RootTag
standard tag.
We need to do the same for the block that represents the first level of the main program.
So, we add a boolean property to the
BlockStmtNode
class,
programBlock
, and make it return true
in the
hasTag()
method
for the RootTag
standard tag
if programBlock
is also true
.
Any tags provided by the language's Nodes need to be registered in the corresponding
TruffleLanguage
class
by annotating it with the
@ProvidedTags
annotation.
Finally, we also need to add a
SourceSection
field to our
RootNode
implementation,
the StmtBlockRootNode
class,
and override the
getSourceSection()
method
using it -- otherwise, the debugger won't work correctly.
With all of that in place,
we can write a standard Java main
method
that evaluates a
sample EasyScript program
that implements the iterative
Fibonacci sequence calculation,
and attaches a debugger to it by specifying the
inspect
option when creating the
Context
object,
whose value will be the port the debugger will listen on
(note that you need the
org.graalvm.tools:chromeinspector
dependency
for this to work).
When you run that program with the ./gradlew :part16:run
command,
it will print out a URL, similar to
devtools://devtools/bundled/js_app.html?ws=127.0.0.1:4242/sQ1fqUidEwEIeQOOh5WsI5Yke6KuTeGPvtOYb03WhVg
.
If you open that URL in Chrome, you should see the debugger suspended at the first line of the sample program:
One thing that's a useful in a debugger is seeing the values of the function arguments and local variables when suspended at a given statement.
In order to add that functionality,
we need to export a new
library, NodeLibrary
,
from the
EasyScriptStmtNode
class,
and override its
hasScope()
and getScope()
methods.
We use the new findParentBlock()
method in the
EasyScriptStmtNode
class
to find the parent block that contains the statement we are suspended on.
There are two debugger scopes:
FuncDebuggerScopeObject
,
that contains the function arguments and local variables on the first level of a user-defined function,
and BlockDebuggerScopeObject
,
which contains local variables from blocks either on the second or lower level of a user-defined function,
or from the main program.
It includes variables from parent blocks by implementing the
getScopeParent()
method
of InteropLibrary
.
Since both scope implementations share a lot of logic of accessing the variables,
they both extend the
AbstractDebuggerScopeObject
class
which contains the common code, to avoid duplication.
AbstractDebuggerScopeObject
operates on another
abstract class, RefObject
,
which represents a reference to either a function argument or a local variable,
which also has two subclasses:
FuncArgRefObject
,
and LocalVarRefObject
.
Normally, this would be a problem for partial evaluation,
so we make sure to cache the RefObject
instances in the
specializations of AbstractDebuggerScopeObject
.
There is also a
special class, RefObjectsArray
,
which implements the
array methods of InteropLibrary
for a collection of RefObject
instances,
returned from the
getMembers()
implementation
of AbstractDebuggerScopeObject
.
And finally, we need to implement the logic of finding these references in the EasyScript AST.
FuncDebuggerScopeObject
and BlockDebuggerScopeObject
delegate that responsibility to
UserFuncBodyStmtNode.getFuncArgAndLocalVarRefs()
and BlockStmtNode.getLocalVarRefs()
,
respectively, since that allows caching the results of the search,
as the structure of the AST doesn't change during the execution of the program.
The actual logic of finding the references is implemented by walking the Nodes of the AST using the
NodeUtil.forEachChild()
method.
For finding local variables, we create a dedicated
NodeVisitor
implementation,
the LocalVarNodeVisitor
class.
Finding local variables is relatively simple, since we change every local variable declaration into a
LocalVarAssignmentExprNode
instance
that is wrapped in an
ExprStmtNode
with the discardExpressionValue
flag set to true
(which we make public
, so that it can be accessed by LocalVarNodeVisitor
).
Function arguments are a little bit more tricky,
since we never explicitly assign them, but simply read them from the
arguments
field of the Frame
object
populated by the Truffle runtime when a
CallTarget
is invoked.
So, we simply look for all instances of the
ReadFunctionArgExprNode
class
in the AST.
This means that any function argument that is never read will not be shown in the debugger,
but that's acceptable, since an argument that is never read is not relevant anyway
(it can't affect the result of the function).
In order to know the original name of the function argument or local variable,
we need to modify both ReadFunctionArgExprNode
and LocalVarAssignmentExprNode
to save that name of the variable that they are referencing
(previously, just their index in the frame was sufficient).
We gather
the FuncArgRefObject
into a Set
,
to de-duplicate multiple reads of the same function argument,
and so we have to make sure to override equals
and hashCode
in that class.
With all of this in place, when you Step Into a function invocation in the debugger, you should see the values of the function arguments and local variables:
A really nice feature of debugger support in Truffle is that it ships with a library,
org.graalvm.truffle:truffle-tck
,
that allows you to control the debugger programmatically,
and thus write unit tests validating the debugger support works as expected.
While the library is not a perfect simulation of Chrome DevTools,
it often has more detailed error reporting -
so, if you run into any issues in the real debugger,
I would encourage you to try to write a unit test simulating the same behavior,
and seeing if the message the test fails with gives you some hint about what the problem might be.
The DebuggerTest
class
shows an example of two unit tests verifying the debugger support works as expected.