packages/core/lib/debug/interpreter.js
const debugModule = require("debug");
const debug = debugModule("lib:debug:interpreter");
const path = require("path");
const util = require("util");
const DebugUtils = require("@truffle/debug-utils");
const selectors = require("@truffle/debugger").selectors;
const { session, sourcemapping, stacktrace, trace, evm, controller } =
selectors;
const analytics = require("../services/analytics");
const repl = require("repl");
const { DebugPrinter } = require("./printer");
const Spinner = require("@truffle/spinners").Spinner;
function watchExpressionAnalytics(raw) {
if (raw.includes("!<")) {
//don't send analytics for watch expressions involving selectors
return;
}
let expression = raw.trim();
//legal Solidity identifiers (= legal JS identifiers)
let identifierRegex = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;
let isVariable = expression.match(identifierRegex) !== null;
analytics.send({
command: "debug: watch expression",
args: { isVariable }
});
}
class DebugInterpreter {
constructor(config, session, txHash) {
this.session = session;
this.network = config.network;
this.fetchExternal = config.fetchExternal;
this.printer = new DebugPrinter(config, session);
this.txHash = txHash;
this.lastCommand = "n";
this.enabledExpressions = new Set();
this.repl = null;
}
async setOrClearBreakpoint(args, setOrClear) {
const breakpoints = this.determineBreakpoints(args); //note: not pure, can print
if (breakpoints !== null) {
for (const breakpoint of breakpoints) {
await this.setOrClearBreakpointObject(breakpoint, setOrClear);
}
} else {
//null is a special value representing all, we'll handle it separately
if (setOrClear) {
// only "B all" is legal, not "b all"
this.printer.print("Cannot add breakpoint everywhere.");
} else {
await this.session.removeAllBreakpoints();
this.printer.print("Removed all breakpoints.");
}
}
}
//NOTE: not pure, also prints!
//returns an array of the breakpoints, unless it's remove all breakpoints,
//in which case it returns null
//(if something goes wrong it will return [] to indicate do nothing)
determineBreakpoints(args) {
const currentLocation = this.session.view(controller.current.location);
const currentStart = currentLocation.sourceRange
? currentLocation.sourceRange.start
: null;
const currentLength = currentLocation.sourceRange
? currentLocation.sourceRange.length
: null;
const currentSourceId = currentLocation.source
? currentLocation.source.id
: null;
const currentLine =
currentSourceId !== null && currentSourceId !== undefined
? //sourceRange is never null, so we go by whether currentSourceId is null/undefined
currentLocation.sourceRange.lines.start.line
: null;
if (args.length === 0) {
//no arguments, want currrent node
debug("node case");
if (currentSourceId === null) {
this.printer.print("Cannot determine current location.");
return [];
}
return [
{
start: currentStart,
line: currentLine, //this isn't necessary for the
//breakpoint to work, but we use it for printing messages
length: currentLength,
sourceId: currentSourceId
}
];
}
//the special case of "B all"
else if (args[0] === "all") {
return null;
}
//if the argument starts with a "+" or "-", we have a relative
//line number
else if (args[0][0] === "+" || args[0][0] === "-") {
debug("relative case");
if (currentLine === null) {
this.printer.print("Cannot determine current location.");
return [];
}
let delta = parseInt(args[0], 10); //want an integer
debug("delta %d", delta);
if (isNaN(delta)) {
this.printer.print("Offset must be an integer.");
return [];
}
return [
{
sourceId: currentSourceId,
line: currentLine + delta
}
];
}
//if it contains a colon, it's in the form source:line
else if (args[0].includes(":")) {
debug("source case");
let sourceArgs = args[0].split(":");
let sourceArg = sourceArgs[0];
let lineArg = sourceArgs[1];
debug("sourceArgs %O", sourceArgs);
//first let's get the line number as usual
let line = parseInt(lineArg, 10); //want an integer
if (isNaN(line)) {
this.printer.print("Line number must be an integer.");
return [];
}
//search sources for given string
let sources = Object.values(
this.session.view(sourcemapping.views.sources)
);
//we will indeed need the sources here, not just IDs
let matchingSources = sources.filter(source =>
source.sourcePath.includes(sourceArg)
);
if (matchingSources.length === 0) {
this.printer.print(`No source file found matching ${sourceArg}.`);
return [];
} else if (matchingSources.length > 1) {
//normally if there's multiple matching sources, we want to return no
//breakpoint and print a disambiguation prompt.
//however, if one of them has a source path that is a substring of all
//the others...
if (
matchingSources.some(shortSource =>
matchingSources.every(
source =>
typeof source.sourcePath !== "string" || //just ignore these I guess?
source.sourcePath.includes(shortSource.sourcePath)
)
)
) {
//exceptional case
this.printer.print(
`WARNING: Acting on all matching sources because disambiguation between them is not possible.`
);
return matchingSources.map(source => ({
sourceId: source.id,
line: line - 1 //adjust for breakpoint!
}));
} else {
//normal case
this.printer.print(
`Multiple source files found matching ${sourceArg}. Which did you mean?`
);
matchingSources.forEach(source =>
this.printer.print(source.sourcePath)
);
this.printer.print("");
return [];
}
}
//otherwise, we found it!
return [
{
sourceId: matchingSources[0].id,
line: line - 1 //adjust for zero-indexing!
}
];
}
//otherwise, it's a simple line number
else {
debug("absolute case");
if (currentSourceId === null || currentSourceId === undefined) {
this.printer.print("Cannot determine current file.");
return [];
}
let line = parseInt(args[0], 10); //want an integer
debug("line %d", line);
if (isNaN(line)) {
this.printer.print("Line number must be an integer.");
return [];
}
return [
{
sourceId: currentSourceId,
line: line - 1 //adjust for zero-indexing!
}
];
}
}
//note: also prints!
async setOrClearBreakpointObject(breakpoint, setOrClear) {
const existingBreakpoints = this.session.view(controller.breakpoints);
//OK, we've constructed the breakpoint! But if we're adding, we'll
//want to adjust to make sure we don't set it on an empty line or
//anything like that
if (setOrClear) {
let resolver = this.session.view(controller.breakpoints.resolver);
breakpoint = resolver(breakpoint);
//of course, this might result in finding that there's nowhere to
//add it after that point
if (breakpoint === null) {
this.printer.print(
"Nowhere to add breakpoint at or beyond that location."
);
return;
}
}
const currentSource = this.session.view(controller.current.location.source);
const currentSourceId = currentSource ? currentSource.id : null;
//having constructed and adjusted breakpoint, here's now a
//user-readable message describing its location
let sources = this.session.view(sourcemapping.views.sources);
let sourceNames = Object.assign(
//note: only include user sources
{},
...Object.entries(sources).map(([id, source]) => ({
[id]: path.basename(source.sourcePath)
}))
);
let locationMessage = DebugUtils.formatBreakpointLocation(
breakpoint,
true, //only relevant for node-based breakpoints
currentSourceId,
sourceNames
);
//one last check -- does this breakpoint already exist?
let alreadyExists =
existingBreakpoints.filter(
existingBreakpoint =>
existingBreakpoint.sourceId === breakpoint.sourceId &&
existingBreakpoint.line === breakpoint.line &&
existingBreakpoint.node === breakpoint.node //may be undefined
).length > 0;
//NOTE: in the "set breakpoint" case, the above check is somewhat
//redundant, as we're going to check again when we actually make the
//call to add or remove the breakpoint! But we need to check here so
//that we can display the appropriate message. Hopefully we can find
//some way to avoid this redundant check in the future.
//if it already exists and is being set, or doesn't and is being
//cleared, report back that we can't do that
if (setOrClear === alreadyExists) {
if (setOrClear) {
this.printer.print(`Breakpoint at ${locationMessage} already exists.`);
return;
} else {
this.printer.print(`No breakpoint at ${locationMessage} to remove.`);
return;
}
}
//finally, if we've reached this point, do it!
//also report back to the user on what happened
if (setOrClear) {
await this.session.addBreakpoint(breakpoint);
this.printer.print(`Breakpoint added at ${locationMessage}.`);
} else {
await this.session.removeBreakpoint(breakpoint);
this.printer.print(`Breakpoint removed at ${locationMessage}.`);
}
}
start(terminate) {
// if terminate is not passed, return a Promise instead
if (terminate === undefined) {
return util.promisify(this.start.bind(this))();
}
if (this.session.view(session.status.loaded)) {
debug("loaded");
this.printer.printSessionLoaded();
} else if (this.session.view(session.status.isError)) {
debug("error!");
this.printer.printSessionError();
} else {
debug("didn't attempt a load");
this.printer.printHelp();
}
const prompt = this.session.view(session.status.loaded)
? DebugUtils.formatPrompt(this.network, this.txHash)
: DebugUtils.formatPrompt(this.network);
this.repl = repl.start({
prompt: prompt,
eval: util.callbackify(this.interpreter.bind(this)),
ignoreUndefined: true,
done: terminate
});
}
async interpreter(cmd) {
cmd = cmd.trim();
let cmdArgs, splitArgs;
debug("cmd %s", cmd);
if (cmd === ".exit") {
cmd = "q";
}
//split arguments for commands that want that; split on runs of spaces
splitArgs = cmd.trim().split(/ +/).slice(1);
debug("splitArgs %O", splitArgs);
//warning: this bit *alters* cmd!
if (cmd.length > 0) {
cmdArgs = cmd.slice(1).trim();
cmd = cmd[0];
}
if (cmd === "") {
cmd = this.lastCommand;
cmdArgs = "";
splitArgs = [];
}
//quit if that's what we were given
if (cmd === "q") {
process.exit();
}
let alreadyFinished = this.session.view(trace.finishedOrUnloaded);
let loadFailed = false;
// If not finished, perform commands that require state changes
// (other than quitting or resetting)
if (!alreadyFinished) {
const stepSpinner = new Spinner(
"core:debug:interpreter:step",
"Stepping..."
);
switch (cmd) {
case "o":
await this.session.stepOver();
break;
case "i":
await this.session.stepInto();
break;
case "u":
await this.session.stepOut();
break;
case "n":
await this.session.stepNext();
break;
case ";":
//two cases -- parameterized and unparameterized
if (cmdArgs !== "") {
let count = parseInt(cmdArgs, 10);
debug("cmdArgs=%s", cmdArgs);
if (isNaN(count)) {
this.printer.print("Number of steps must be an integer.");
break;
}
await this.session.advance(count);
} else {
await this.session.advance();
}
break;
case "c":
await this.session.continueUntilBreakpoint();
break;
}
stepSpinner.remove();
} //otherwise, inform the user we can't do that
else {
switch (cmd) {
case "o":
case "i":
case "u":
case "n":
case "c":
case ";":
//are we "finished" because we've reached the end, or because
//nothing is loaded?
if (this.session.view(session.status.loaded)) {
this.printer.print("Transaction has halted; cannot advance.");
this.printer.print("");
} else {
this.printer.print("No transaction loaded.");
this.printer.print("");
}
}
}
if (cmd === "r") {
//reset if given the reset command
//(but not if nothing is loaded)
if (this.session.view(session.status.loaded)) {
await this.session.reset();
} else {
this.printer.print("No transaction loaded.");
this.printer.print("");
}
}
if (cmd === "y") {
if (this.session.view(session.status.loaded)) {
if (this.session.view(trace.finished)) {
if (!this.session.view(evm.current.step.isExceptionalHalting)) {
const errorIndex = this.session.view(
stacktrace.current.innerErrorIndex
);
if (errorIndex !== null) {
const stepSpinner = new Spinner(
"core:debug:interpreter:step",
"Stepping..."
);
await this.session.reset();
await this.session.advance(errorIndex);
stepSpinner.remove();
} else {
this.printer.print("No error to return to.");
}
} else {
this.printer.print("You are already at the final error.");
this.printer.print(
"Use the `Y` command to return to the previous error."
);
this.printer.print("");
}
} else {
this.printer.print(
"This command is only usable at end of transaction; did you mean `Y`?"
);
}
} else {
this.printer.print("No transaction loaded.");
this.printer.print("");
}
}
if (cmd === "Y") {
if (this.session.view(session.status.loaded)) {
const errorIndex = this.session.view(
stacktrace.current.innerErrorIndex
);
if (errorIndex !== null) {
const stepSpinner = new Spinner(
"core:debug:interpreter:step",
"Stepping..."
);
await this.session.reset();
await this.session.advance(errorIndex);
stepSpinner.remove();
} else {
this.printer.print("No previous error to return to.");
}
} else {
this.printer.print("No transaction loaded.");
this.printer.print("");
}
}
if (cmd === "t") {
if (!this.fetchExternal) {
if (!this.session.view(session.status.loaded)) {
const txSpinner = new Spinner(
"core:debug:interpreter:step",
DebugUtils.formatTransactionStartMessage()
);
try {
await this.session.load(cmdArgs);
txSpinner.succeed();
this.repl.setPrompt(DebugUtils.formatPrompt(this.network, cmdArgs));
} catch (_) {
txSpinner.fail();
loadFailed = true;
}
} else {
loadFailed = true;
this.printer.print(
"Please unload the current transaction before loading a new one."
);
}
} else {
loadFailed = true;
this.printer.print(
"Cannot change transactions in fetch-external mode. Please quit and restart the debugger instead."
);
}
}
if (cmd === "T") {
if (!this.fetchExternal) {
if (this.session.view(session.status.loaded)) {
await this.session.unload();
this.printer.print("Transaction unloaded.");
this.repl.setPrompt(DebugUtils.formatPrompt(this.network));
} else {
this.printer.print("No transaction to unload.");
this.printer.print("");
}
} else {
this.printer.print(
"Cannot change transactions in fetch-external mode. Please quit and restart the debugger instead."
);
}
}
if (cmd === "g") {
if (!this.session.view(controller.stepIntoInternalSources)) {
this.session.setInternalStepping(true);
this.printer.print(
"All debugger commands can now step into generated sources."
);
} else {
this.printer.print("Generated sources already activated.");
}
}
if (cmd === "G") {
if (this.session.view(controller.stepIntoInternalSources)) {
this.session.setInternalStepping(false);
this.printer.print(
"Commands other than (;) and (c) will now skip over generated sources."
);
} else {
this.printer.print("Generated sources already off.");
}
}
// Check if execution has (just now) stopped.
if (this.session.view(trace.finished) && !alreadyFinished) {
this.printer.print("");
//check if transaction failed
if (!this.session.view(evm.transaction.status)) {
await this.printer.printRevertMessage();
this.printer.print("");
this.printer.printStacktrace(true); //final stacktrace
this.printer.print("");
this.printer.printErrorLocation();
} else {
//case if transaction succeeded
this.printer.print("Transaction completed successfully.");
if (
this.session.view(sourcemapping.current.source).language !== "Vyper"
) {
//HACK: not supported for vyper yet
await this.printer.printReturnValue();
}
}
}
// Perform post printing
// (we want to see if execution stopped before printing state).
switch (cmd) {
case "+":
if (cmdArgs[0] === ":") {
watchExpressionAnalytics(cmdArgs.substring(1));
}
this.enabledExpressions.add(cmdArgs);
await this.printer.printWatchExpressionResult(cmdArgs);
break;
case "-":
this.enabledExpressions.delete(cmdArgs);
break;
case "!":
this.printer.printSelector(cmdArgs);
break;
case "?":
this.printer.printWatchExpressions(this.enabledExpressions);
this.printer.printBreakpoints();
this.printer.printGeneratedSourcesState();
break;
case "v":
if (
this.session.view(sourcemapping.current.source).language === "Vyper"
) {
this.printer.print(
"Decoding of variables is not currently supported for Vyper."
);
break;
}
//first: process which sections we should print out
const tempPrintouts = this.updatePrintouts(
splitArgs,
this.printer.sections,
this.printer.sectionPrintouts
);
await this.printer.printVariables(tempPrintouts);
if (this.session.view(trace.finished)) {
await this.printer.printReturnValue();
}
break;
case "e":
if (cmdArgs) {
const eventsCount = parseInt(cmdArgs);
if (!isNaN(eventsCount) && eventsCount > 0) {
this.printer.eventsCount = eventsCount;
} else if (cmdArgs === "all") {
this.printer.eventsCount = Infinity;
} else {
this.printer.print(
'Invalid event count given, must be positive integer or "all"'
);
break;
}
}
if (this.session.view(session.status.loaded)) {
this.printer.printEvents();
} else {
this.printer.print("No transaction loaded to print events for.");
}
break;
case ":":
watchExpressionAnalytics(cmdArgs);
this.printer.evalAndPrintExpression(cmdArgs);
break;
case "b":
await this.setOrClearBreakpoint(splitArgs, true);
break;
case "B":
await this.setOrClearBreakpoint(splitArgs, false);
break;
case "p":
// determine the numbers of instructions to be printed
this.printer.instructionLines = this.parsePrintoutLines(
splitArgs,
this.printer.instructionLines
);
// process which locations we should print out
const temporaryPrintouts = this.updatePrintouts(
splitArgs,
this.printer.locations,
this.printer.locationPrintouts
);
if (this.session.view(session.status.loaded)) {
if (this.session.view(trace.steps).length > 0) {
this.printer.printInstruction(temporaryPrintouts);
this.printer.printFile();
this.printer.printState();
} else {
//if there are no trace steps, let's just print a warning message
this.printer.print("No trace steps to inspect.");
}
}
//finally, print watch expressions
await this.printer.printWatchExpressionsResults(
this.enabledExpressions
);
break;
case "l":
if (this.session.view(session.status.loaded)) {
this.printer.printFile();
// determine the numbers of lines to be printed
this.printer.sourceLines = this.parsePrintoutLines(
splitArgs,
this.printer.sourceLines
);
this.printer.printState(
this.printer.sourceLines.beforeLines,
this.printer.sourceLines.afterLines
);
}
break;
case ";":
if (!this.session.view(trace.finishedOrUnloaded)) {
this.printer.printInstruction();
this.printer.printFile();
this.printer.printState();
}
await this.printer.printWatchExpressionsResults(
this.enabledExpressions
);
break;
case "s":
if (this.session.view(session.status.loaded)) {
//print final report if finished & failed, intermediate if not
if (
this.session.view(trace.finished) &&
!this.session.view(evm.transaction.status)
) {
this.printer.printStacktrace(true); //print final stack trace
//Now: actually show the point where things went wrong
this.printer.printErrorLocation(
this.printer.sourceLines.beforeLines,
this.printer.sourceLines.afterLines
);
} else {
this.printer.printStacktrace(false); //intermediate call stack
}
}
break;
case "o":
case "i":
case "u":
case "n":
case "c":
case "y":
case "Y":
if (!this.session.view(trace.finishedOrUnloaded)) {
if (!this.session.view(sourcemapping.current.source).source) {
this.printer.printInstruction();
}
this.printer.printFile();
this.printer.printState();
}
await this.printer.printWatchExpressionsResults(
this.enabledExpressions
);
break;
case "r":
if (this.session.view(session.status.loaded)) {
this.printer.printAddressesAffected();
this.printer.warnIfNoSteps();
this.printer.printFile();
this.printer.printState();
}
break;
case "t":
if (!loadFailed) {
this.printer.printAddressesAffected();
this.printer.warnIfNoSteps();
this.printer.printFile();
this.printer.printState();
} else if (this.session.view(session.status.isError)) {
let loadError = this.session.view(session.status.error);
this.printer.print(loadError);
}
break;
case "T":
case "g":
case "G":
//nothing to print
break;
default:
this.printer.printHelp(this.lastCommand);
}
const nonRepeatableCommands = "bBvhpl?!:+r-tTgGsye";
if (!nonRepeatableCommands.includes(cmd)) {
this.lastCommand = cmd;
}
}
// update the printouts according to user inputs
// called by case v for section printouts and case p for location printouts
//
// NOTE: THIS FUNCTION IS NOT PURE.
// The input printOuts is altered according to the values of other inputs: userArgs and selections.
// The function returns an object, tempPrintouts, that contains the selected printouts.
updatePrintouts(userArgs, selections, printOuts) {
let tempPrintouts = new Set();
for (let argument of userArgs) {
let fullSelection;
if (argument[0] === "+" || argument[0] === "-") {
fullSelection = argument.slice(1);
} else {
fullSelection = argument;
}
let selection = selections.find(possibleSelection =>
fullSelection.startsWith(possibleSelection)
);
if (argument[0] === "+") {
printOuts.add(selection);
} else if (argument[0] === "-") {
printOuts.delete(selection);
} else {
tempPrintouts.add(selection);
}
}
for (let selection of printOuts) {
debug("selection: %s", selection);
tempPrintouts.add(selection);
}
return tempPrintouts;
}
// parse the numbers of lines options -<num>|+<num> from user args
parsePrintoutLines(userArgs, currentLines) {
let { beforeLines, afterLines } = currentLines;
for (const argument of userArgs) {
// ignore an option with length less than 2,such as a bare + or -
if (argument.length < 2) continue;
const newLines = Number(argument.slice(1));
// ignore the arguments that are not of the correct form, number
if (isNaN(newLines)) continue;
if (argument[0] === "-") {
beforeLines = newLines;
} else if (argument[0] === "+") {
afterLines = newLines;
}
}
return { beforeLines, afterLines };
}
}
module.exports = {
DebugInterpreter
};