text: const debugModule = require("debug");
  const debug = debugModule("lib:debug:interpreter");
  
  const path = require("path");
  const util = require("util");
  const ora = require("ora");
  
  const DebugUtils = require("@truffle/debug-utils");
  const selectors = require("@truffle/debugger").selectors;
  const { session, solidity, trace, evm, controller, stacktrace } = selectors;
  
  const analytics = require("../services/analytics");
  const repl = require("repl");
  
  const { DebugPrinter } = require("./printer");
  
  const LINES_BEFORE_LONG = 5;
  const LINES_AFTER_LONG = 3;
  
  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) {
      //setOrClear: true for set, false for clear
      const currentLocation = this.session.view(controller.current.location);
      const breakpoints = this.session.view(controller.breakpoints);
  
      const currentNode = currentLocation.astRef;
      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;
  
      let breakpoint = {};
  
      if (args.length === 0) {
        //no arguments, want currrent node
        debug("node case");
        if (currentNode === null) {
          this.printer.print("Cannot determine current location.");
          return;
        }
        breakpoint.node = currentNode;
        breakpoint.line = currentLine;
        breakpoint.sourceId = currentSourceId;
      }
  
      //the special case of "B all"
      else if (args[0] === "all") {
        if (setOrClear) {
          // only "B all" is legal, not "b all"
          this.printer.print("Cannot add breakpoint everywhere.");
          return;
        }
        await this.session.removeAllBreakpoints();
        this.printer.print("Removed all breakpoints.");
        return;
      }
  
      //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;
        }
  
        breakpoint.sourceId = currentSourceId;
        breakpoint.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(solidity.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) {
          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!
        breakpoint.sourceId = matchingSources[0].id;
        breakpoint.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;
        }
  
        breakpoint.sourceId = currentSourceId;
        breakpoint.line = line - 1; //adjust for zero-indexing!
      }
  
      //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;
        }
      }
  
      //having constructed and adjusted breakpoint, here's now a
      //user-readable message describing its location
      let sources = this.session.view(solidity.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 =
        breakpoints.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}.`);
      }
      return;
    }
  
    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) {
        let stepSpinner = ora("Stepping...").start();
        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.stop();
      } //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(selectors.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(selectors.session.status.loaded)) {
          await this.session.reset();
        } else {
          this.printer.print("No transaction loaded.");
          this.printer.print("");
        }
      }
      if (cmd === "t") {
        if (!this.fetchExternal) {
          if (!this.session.view(selectors.session.status.loaded)) {
            let txSpinner = ora(
              DebugUtils.formatTransactionStartMessage()
            ).start();
            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(selectors.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") {
        this.session.setInternalStepping(true);
        this.printer.print(
          "All debugger commands can now step into generated sources."
        );
      }
      if (cmd === "G") {
        this.session.setInternalStepping(false);
        this.printer.print(
          "Commands other than (;) and (c) will now skip over generated sources."
        );
      }
  
      // 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.print(DebugUtils.truffleColors.red("Location of error:"));
          const stacktraceReport = this.session.view(
            stacktrace.current.finalReport
          );
          const recordedLocation =
            stacktraceReport[stacktraceReport.length - 1].location;
          this.printer.printFile(recordedLocation);
          this.printer.printState(undefined, undefined, recordedLocation);
        } else {
          //case if transaction succeeded
          this.printer.print("Transaction completed successfully.");
          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();
          break;
        case "v":
          await this.printer.printVariables();
          if (this.session.view(trace.finished)) {
            await this.printer.printReturnValue();
          }
          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":
          //first: process which locations we should print out
          let temporaryPrintouts = new Set();
          for (let argument of splitArgs) {
            let fullLocation;
            if (argument[0] === "+" || argument[0] === "-") {
              fullLocation = argument.slice(1);
            } else {
              fullLocation = argument;
            }
            let location = this.printer.locations.find(possibleLocation =>
              fullLocation.startsWith(possibleLocation)
            );
            if (argument[0] === "+") {
              this.printer.printouts.add(location);
            } else if (argument[0] === "-") {
              this.printer.printouts.delete(location);
            } else {
              temporaryPrintouts.add(location);
            }
          }
          for (let location of this.printer.printouts) {
            debug("location: %s", location);
            temporaryPrintouts.add(location);
          }
          if (this.session.view(selectors.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(selectors.session.status.loaded)) {
            this.printer.printFile();
            this.printer.printState(LINES_BEFORE_LONG, LINES_AFTER_LONG);
          }
          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(selectors.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.print("");
              this.printer.print(
                DebugUtils.truffleColors.red("Location of error:")
              );
              const stacktraceReport = this.session.view(
                stacktrace.current.finalReport
              );
              const recordedLocation =
                stacktraceReport[stacktraceReport.length - 1].location;
              this.printer.printFile(recordedLocation);
              this.printer.printState(
                LINES_BEFORE_LONG,
                LINES_AFTER_LONG,
                recordedLocation
              );
            } else {
              this.printer.printStacktrace(false); //intermediate call stack
            }
          }
          break;
        case "o":
        case "i":
        case "u":
        case "n":
        case "c":
          if (!this.session.view(trace.finishedOrUnloaded)) {
            if (!this.session.view(solidity.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(selectors.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(selectors.session.status.isError)) {
            let loadError = this.session.view(selectors.session.status.error);
            this.printer.print(loadError);
          }
          break;
        case "T":
        case "g":
        case "G":
          //nothing to print
          break;
        default:
          this.printer.printHelp(this.lastCommand);
      }
  
      if (
        cmd !== "b" &&
        cmd !== "B" &&
        cmd !== "v" &&
        cmd !== "h" &&
        cmd !== "p" &&
        cmd !== "l" &&
        cmd !== "?" &&
        cmd !== "!" &&
        cmd !== ":" &&
        cmd !== "+" &&
        cmd !== "r" &&
        cmd !== "-" &&
        cmd !== "t" &&
        cmd !== "T" &&
        cmd !== "g" &&
        cmd !== "G" &&
        cmd !== "s"
      ) {
        this.lastCommand = cmd;
      }
    }
  }
  
  module.exports = {
    DebugInterpreter
  };
  