1 /* -*- indent-tabs-mode: nil; js-indent-level: 4 -*-
2 * vim: set ts=8 sw=4 et tw=78:
4 * jorendb - A toy command-line debugger for shell-js programs.
6 * This Source Code Form is subject to the terms of the Mozilla Public
7 * License, v. 2.0. If a copy of the MPL was not distributed with this
8 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
12 * jorendb is a simple command-line debugger for shell-js programs. It is
13 * intended as a demo of the Debugger object (as there are no shell js programs
16 * To run it: $JS -d path/to/this/file/jorendb.js
17 * To run some JS code under it, try:
18 * (jorendb) print load("my-script-to-debug.js")
19 * Execution will stop at debugger statements and you'll get a jorendb prompt.
23 var focusedFrame = null;
25 var debuggeeValues = {};
26 var nextDebuggeeValueIndex = 1;
30 var options = { 'pretty': true,
31 'emacs': !!os.getenv('INSIDE_EMACS') };
34 // Cleanup functions to run when we next re-enter the repl.
35 var replCleanups = [];
37 // Redirect debugger printing functions to go to the original output
38 // destination, unaffected by any redirects done by the debugged script.
39 var initialOut = os.file.redirect();
40 var initialErr = os.file.redirectErr();
42 function wrap(global, name) {
43 var orig = global[name];
44 global[name] = function(...args) {
46 var oldOut = os.file.redirect(initialOut);
47 var oldErr = os.file.redirectErr(initialErr);
49 return orig.apply(global, args);
51 os.file.redirect(oldOut);
52 os.file.redirectErr(oldErr);
57 wrap(this, 'printErr');
60 // Convert a debuggee value v to a string.
61 function dvToString(v) {
62 if (typeof(v) === 'object' && v !== null) {
63 return `[object ${v.class}]`;
67 return s.substr(0, 400) + "...<" + (s.length - 400) + " more bytes>...";
72 function summaryObject(dv) {
74 for (var name of dv.getOwnPropertyNames()) {
75 var v = dv.getOwnPropertyDescriptor(name).value;
76 if (v instanceof Debugger.Object) {
84 function debuggeeValueToString(dv, style) {
85 var dvrepr = dvToString(dv);
86 if (!style.pretty || (typeof dv !== 'object') || (dv === null))
87 return [dvrepr, undefined];
89 const exec = debuggeeGlobalWrapper.executeInGlobalWithBindings.bind(debuggeeGlobalWrapper);
91 if (dv.class == "Error") {
92 let errval = exec("$$.toString()", debuggeeValues);
93 return [dvrepr, errval.return];
97 return [dvrepr, JSON.stringify(summaryObject(dv), null, 4)];
99 let str = exec("JSON.stringify(v, null, 4)", {v: dv});
100 if ('throw' in str) {
102 return [dvrepr, undefined];
105 Object.assign(substyle, style);
106 substyle.noerror = true;
107 return [dvrepr, debuggeeValueToString(str.throw, substyle)];
110 return [dvrepr, str.return];
113 // Problem! Used to do [object Object] followed by details. Now just details?
115 function showDebuggeeValue(dv, style={pretty: options.pretty}) {
116 var i = nextDebuggeeValueIndex++;
117 debuggeeValues["$" + i] = dv;
118 debuggeeValues["$$"] = dv;
119 let [brief, full] = debuggeeValueToString(dv, style);
120 print("$" + i + " = " + brief);
121 if (full !== undefined)
125 Object.defineProperty(Debugger.Frame.prototype, "num", {
130 for (var f = topFrame; f && f !== this; f = f.older)
132 return f === null ? undefined : i;
136 Debugger.Frame.prototype.frameDescription = function frameDescription() {
137 if (this.type == "call")
138 return ((this.callee.name || '<anonymous>') +
139 "(" + this.arguments.map(dvToString).join(", ") + ")");
141 return this.type + " code";
144 Debugger.Frame.prototype.positionDescription = function positionDescription() {
146 var line = this.script.getOffsetLocation(this.offset).lineNumber;
148 return this.script.url + ":" + line;
149 return "line " + line;
154 Debugger.Frame.prototype.location = function () {
156 var { lineNumber, columnNumber, isEntryPoint } = this.script.getOffsetLocation(this.offset);
158 return this.script.url + ":" + lineNumber;
164 Debugger.Frame.prototype.fullDescription = function fullDescription() {
165 var fr = this.frameDescription();
166 var pos = this.positionDescription();
168 return fr + ", " + pos;
172 Object.defineProperty(Debugger.Frame.prototype, "line", {
177 return this.script.getOffsetLocation(this.offset).lineNumber;
183 function callDescription(f) {
184 return ((f.callee.name || '<anonymous>') +
185 "(" + f.arguments.map(dvToString).join(", ") + ")");
188 function showFrame(f, n) {
189 if (f === undefined || f === null) {
196 if (n === undefined) {
199 throw new Error("Internal error: frame not on stack");
202 print('#' + n + " " + f.fullDescription());
205 function saveExcursion(fn) {
206 var tf = topFrame, ff = focusedFrame;
215 function parseArgs(str) {
216 return str.split(" ");
219 function describedRv(r, desc) {
220 desc = "[" + desc + "] ";
221 if (r === undefined) {
222 print(desc + "Returning undefined");
223 } else if (r === null) {
224 print(desc + "Returning null");
225 } else if (r.length === undefined) {
226 print(desc + "Returning object " + JSON.stringify(r));
228 print(desc + "Returning length-" + r.length + " list");
236 // Rerun the program (reloading it from the file)
237 function runCommand(args) {
238 print(`Restarting program (${args})`);
240 activeTask.scriptArgs = parseArgs(args);
242 activeTask.scriptArgs = [...actualScriptArgs];
244 for (var f = topFrame; f; f = f.older) {
246 f.onPop = () => null;
248 f.onPop = () => ({ 'return': 0 });
251 //return describedRv([{ 'return': 0 }], "runCommand");
255 // Evaluate an expression in the Debugger global
256 function evalCommand(expr) {
260 function quitCommand() {
261 dbg.removeAllDebuggees();
265 function backtraceCommand() {
266 if (topFrame === null)
268 for (var i = 0, f = topFrame; f; i++, f = f.older)
272 function setCommand(rest) {
273 var space = rest.indexOf(' ');
275 print("Invalid set <option> <value> command");
277 var name = rest.substr(0, space);
278 var value = rest.substr(space + 1);
280 if (name == 'args') {
281 activeTask.scriptArgs = parseArgs(value);
283 var yes = ["1", "yes", "true", "on"];
284 var no = ["0", "no", "false", "off"];
286 if (yes.includes(value))
287 options[name] = true;
288 else if (no.includes(value))
289 options[name] = false;
291 options[name] = value;
296 function split_print_options(s, style) {
297 var m = /^\/(\w+)/.exec(s);
300 if (m[1].includes("p"))
302 if (m[1].includes("b"))
304 return [ s.substr(m[0].length).trimLeft(), style ];
307 function doPrint(expr, style) {
308 // This is the real deal.
309 var cv = saveExcursion(
310 () => focusedFrame == null
311 ? debuggeeGlobalWrapper.executeInGlobalWithBindings(expr, debuggeeValues)
312 : focusedFrame.evalWithBindings(expr, debuggeeValues));
314 print("Debuggee died.");
315 } else if ('return' in cv) {
316 showDebuggeeValue(cv.return, style);
318 print("Exception caught. (To rethrow it, type 'throw'.)");
320 showDebuggeeValue(lastExc, style);
324 function printCommand(rest) {
325 var [expr, style] = split_print_options(rest, {pretty: options.pretty});
326 return doPrint(expr, style);
329 function keysCommand(rest) { return doPrint("Object.keys(" + rest + ")"); }
331 function detachCommand() {
332 dbg.removeAllDebuggees();
336 function continueCommand(rest) {
337 if (focusedFrame === null) {
342 var match = rest.match(/^(\d+)$/);
344 return doStepOrNext({upto:true, stopLine:match[1]});
350 function throwCommand(rest) {
352 if (focusedFrame !== topFrame) {
353 print("To throw, you must select the newest frame (use 'frame 0').");
355 } else if (focusedFrame === null) {
358 } else if (rest === '') {
359 return [{throw: lastExc}];
361 var cv = saveExcursion(function () { return focusedFrame.eval(rest); });
363 print("Debuggee died while determining what to throw. Stopped.");
364 } else if ('return' in cv) {
365 return [{throw: cv.return}];
367 print("Exception determining what to throw. Stopped.");
368 showDebuggeeValue(cv.throw);
374 function frameCommand(rest) {
376 if (rest.match(/[0-9]+/)) {
383 for (var i = 0; i < n && f; i++) {
385 print("There is no frame " + rest + ".");
392 updateLocation(focusedFrame);
394 } else if (rest === '') {
395 if (topFrame === null) {
398 updateLocation(focusedFrame);
402 print("do what now?");
406 function upCommand() {
407 if (focusedFrame === null)
409 else if (focusedFrame.older === null)
410 print("Initial frame selected; you cannot go up.");
412 focusedFrame.older.younger = focusedFrame;
413 focusedFrame = focusedFrame.older;
414 updateLocation(focusedFrame);
419 function downCommand() {
420 if (focusedFrame === null)
422 else if (!focusedFrame.younger)
423 print("Youngest frame selected; you cannot go down.");
425 focusedFrame = focusedFrame.younger;
426 updateLocation(focusedFrame);
431 function forcereturnCommand(rest) {
433 var f = focusedFrame;
434 if (f !== topFrame) {
435 print("To forcereturn, you must select the newest frame (use 'frame 0').");
436 } else if (f === null) {
437 print("Nothing on the stack.");
438 } else if (rest === '') {
439 return [{return: undefined}];
441 var cv = saveExcursion(function () { return f.eval(rest); });
443 print("Debuggee died while determining what to forcereturn. Stopped.");
444 } else if ('return' in cv) {
445 return [{return: cv.return}];
447 print("Error determining what to forcereturn. Stopped.");
448 showDebuggeeValue(cv.throw);
453 function printPop(f, c) {
454 var fdesc = f.fullDescription();
456 print("frame returning (still selected): " + fdesc);
457 showDebuggeeValue(c.return, {brief: true});
458 } else if (c.throw) {
459 print("frame threw exception: " + fdesc);
460 showDebuggeeValue(c.throw);
461 print("(To rethrow it, type 'throw'.)");
464 print("frame was terminated: " + fdesc);
468 // Set |prop| on |obj| to |value|, but then restore its current value
469 // when we next enter the repl.
470 function setUntilRepl(obj, prop, value) {
471 var saved = obj[prop];
473 replCleanups.push(function () { obj[prop] = saved; });
476 function updateLocation(frame) {
478 var loc = frame.location();
480 print("\032\032" + loc + ":1");
484 function doStepOrNext(kind) {
485 var startFrame = topFrame;
486 var startLine = startFrame.line;
487 // print("stepping in: " + startFrame.fullDescription());
488 // print("starting line: " + uneval(startLine));
490 function stepPopped(completion) {
491 // Note that we're popping this frame; we need to watch for
492 // subsequent step events on its caller.
493 this.reportedPop = true;
494 printPop(this, completion);
495 topFrame = focusedFrame = this;
497 // We want to continue, but this frame is going to be invalid as
498 // soon as this function returns, which will make the replCleanups
499 // assert when it tries to access the dead frame's 'onPop'
500 // property. So clear it out now while the frame is still valid,
501 // and trade it for an 'onStep' callback on the frame we're popping to.
503 setUntilRepl(this.older, 'onStep', stepStepped);
506 updateLocation(this);
510 function stepEntered(newFrame) {
511 print("entered frame: " + newFrame.fullDescription());
512 updateLocation(newFrame);
513 topFrame = focusedFrame = newFrame;
517 function stepStepped() {
518 // print("stepStepped: " + this.fullDescription());
519 updateLocation(this);
523 // 'finish' set a one-time onStep for stopping at the frame it
524 // wants to return to
526 } else if (kind.upto) {
527 // running until a given line is reached
528 if (this.line == kind.stopLine)
531 // regular step; stop whenever the line number changes
532 if ((this.line != startLine) || (this != startFrame))
537 topFrame = focusedFrame = this;
538 if (focusedFrame != startFrame)
539 print(focusedFrame.fullDescription());
543 // Otherwise, let execution continue.
548 setUntilRepl(dbg, 'onEnterFrame', stepEntered);
550 // If we're stepping after an onPop, watch for steps and pops in the
551 // next-older frame; this one is done.
552 var stepFrame = startFrame.reportedPop ? startFrame.older : startFrame;
553 if (!stepFrame || !stepFrame.script)
557 setUntilRepl(stepFrame, 'onStep', stepStepped);
558 setUntilRepl(stepFrame, 'onPop', stepPopped);
561 // Let the program continue!
565 function stepCommand() { return doStepOrNext({step:true}); }
566 function nextCommand() { return doStepOrNext({next:true}); }
567 function finishCommand() { return doStepOrNext({finish:true}); }
569 // FIXME: DOES NOT WORK YET
570 function breakpointCommand(where) {
571 print("Sorry, breakpoints don't work yet.");
572 var script = focusedFrame.script;
573 var offsets = script.getLineOffsets(Number(where));
574 if (offsets.length == 0) {
575 print("Unable to break at line " + where);
578 for (var offset of offsets) {
579 script.setBreakpoint(offset, { hit: handleBreakpoint });
581 print("Set breakpoint in " + script.url + ":" + script.startLine + " at line " + where + ", " + offsets.length);
584 // Build the table of commands.
587 backtraceCommand, "bt", "where",
588 breakpointCommand, "b", "break",
589 continueCommand, "c",
595 finishCommand, "fin",
607 var currentCmd = null;
608 for (var i = 0; i < commandArray.length; i++) {
609 var cmd = commandArray[i];
610 if (typeof cmd === "string")
611 commands[cmd] = currentCmd;
613 currentCmd = commands[cmd.name.replace(/Command$/, '')] = cmd;
616 function helpCommand(rest) {
617 print("Available commands:");
618 var printcmd = function(group) {
619 print(" " + group.join(", "));
623 for (var cmd of commandArray) {
624 if (typeof cmd === "string") {
627 if (group.length) printcmd(group);
628 group = [ cmd.name.replace(/Command$/, '') ];
634 // Break cmd into two parts: its first word and everything else. If it begins
635 // with punctuation, treat that as a separate word. The first word is
636 // terminated with whitespace or the '/' character. So:
638 // print x => ['print', 'x']
639 // print => ['print', '']
640 // !print x => ['!', 'print x']
641 // ?!wtf!? => ['?', '!wtf!?']
642 // print/b x => ['print', '/b x']
644 function breakcmd(cmd) {
645 cmd = cmd.trimLeft();
646 if ("!@#$%^&*_+=/?.,<>:;'\"".includes(cmd.substr(0, 1)))
647 return [cmd.substr(0, 1), cmd.substr(1).trimLeft()];
648 var m = /\s+|(?=\/)/.exec(cmd);
651 return [cmd.slice(0, m.index), cmd.slice(m.index + m[0].length)];
654 function runcmd(cmd) {
655 var pieces = breakcmd(cmd);
656 if (pieces[0] === "")
659 var first = pieces[0], rest = pieces[1];
660 if (!commands.hasOwnProperty(first)) {
661 print("unrecognized command '" + first + "'");
665 var cmd = commands[first];
666 if (cmd.length === 0 && rest !== '') {
667 print("this command cannot take an argument");
674 function preReplCleanups() {
675 while (replCleanups.length > 0)
676 replCleanups.pop()();
679 var prevcmd = undefined;
685 putstr("\n" + prompt);
694 var result = runcmd(cmd);
695 if (result === undefined)
696 ; // do nothing, return to prompt
697 else if (Array.isArray(result))
699 else if (result === null)
702 throw new Error("Internal error: result of runcmd wasn't array or undefined: " + result);
704 print("*** Internal error: exception in the debugger code.");
711 var dbg = new Debugger();
712 dbg.onDebuggerStatement = function (frame) {
713 return saveExcursion(function () {
714 topFrame = focusedFrame = frame;
715 print("'debugger' statement hit.");
717 updateLocation(focusedFrame);
719 return describedRv(repl(), "debugger.saveExc");
722 dbg.onThrow = function (frame, exc) {
723 return saveExcursion(function () {
724 topFrame = focusedFrame = frame;
725 print("Unwinding due to exception. (Type 'c' to continue unwinding.)");
727 print("Exception value is:");
728 showDebuggeeValue(exc);
733 function handleBreakpoint (frame) {
734 print("Breakpoint hit!");
735 return saveExcursion(() => {
736 topFrame = focusedFrame = frame;
737 print("breakpoint hit.");
739 updateLocation(focusedFrame);
744 // The depth of jorendb nesting.
746 if (typeof jorendbDepth == 'undefined') jorendbDepth = 0;
748 var debuggeeGlobal = newGlobal({newCompartment: true});
749 debuggeeGlobal.jorendbDepth = jorendbDepth + 1;
750 var debuggeeGlobalWrapper = dbg.addDebuggee(debuggeeGlobal);
752 print("jorendb version -0.0");
753 prompt = '(' + Array(jorendbDepth+1).join('meta-') + 'jorendb) ';
755 var args = scriptArgs.slice(0);
756 print("INITIAL ARGS: " + args);
758 // Find the script to run and its arguments. The script may have been given as
759 // a plain script name, in which case all remaining arguments belong to the
760 // script. Or there may have been any number of arguments to the JS shell,
761 // followed by -f scriptName, followed by additional arguments to the JS shell,
762 // followed by the script arguments. There may be multiple -e or -f options in
763 // the JS shell arguments, and we want to treat each one as a debuggable
766 // The difficulty is that the JS shell has a mixture of
774 // parameters, and there's no way to know whether --option takes an argument or
775 // not. We will assume that VAL will never end in .js, or rather that the first
776 // argument that does not start with "-" but does end in ".js" is the name of
779 // If you need to pass other options and not have them given to the script,
780 // pass them before the -f jorendb.js argument. Thus, the safe ways to pass
783 // js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)+ -- [script args]
784 // js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)* script.js [script args]
786 // Additionally, if you want to run a script that is *NOT* debugged, put it in
787 // as part of the leading [JS shell options].
790 // Compute actualScriptArgs by finding the script to be run and grabbing every
791 // non-script argument. The script may be given by -f scriptname or just plain
792 // scriptname. In the latter case, it will be in the global variable
793 // 'scriptPath' (and NOT in scriptArgs.)
794 var actualScriptArgs = [];
797 if (scriptPath !== undefined) {
800 'script': scriptPath,
805 while(args.length > 0) {
806 var arg = args.shift();
807 print("arg: " + arg);
814 } else if (arg == '-f') {
815 var script = args.shift();
816 print(" load -f " + script);
822 } else if (arg.indexOf("-") == 0) {
824 print(" pass remaining args to script");
825 actualScriptArgs.push(...args);
827 } else if ((args.length > 0) && (args[0].indexOf(".js") + 3 == args[0].length)) {
828 // Ends with .js, assume we are looking at --boolean script.js
829 print(" load script.js after --boolean");
832 'script': args.shift(),
836 // Does not end with .js, assume we are looking at JS shell arg
843 print(" load general");
844 actualScriptArgs.push(...args);
851 print(" arg " + arg);
852 actualScriptArgs.push(arg);
856 print("jorendb: scriptPath = " + scriptPath);
857 print("jorendb: scriptArgs = " + scriptArgs);
858 print("jorendb: actualScriptArgs = " + actualScriptArgs);
860 for (var task of todo) {
861 task['scriptArgs'] = [...actualScriptArgs];
864 // Always drop into a repl at the end. Especially if the main script throws an
866 todo.push({ 'action': 'repl' });
869 print("Top of run loop");
871 for (var task of todo) {
873 if (task.action == 'eval') {
874 debuggeeGlobal.eval(task.code);
875 } else if (task.action == 'load') {
876 debuggeeGlobal['scriptArgs'] = task.scriptArgs;
877 debuggeeGlobal['scriptPath'] = task.script;
878 print("Loading JavaScript file " + task.script);
880 debuggeeGlobal.evaluate(read(task.script), { 'fileName': task.script, 'lineNumber': 1 });
882 print("Caught exception " + exc);
886 } else if (task.action == 'repl') {