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 return (typeof v !== 'object' || v === null) ? uneval(v) : "[object " + v.class + "]";
65 function summaryObject(dv) {
67 for (var name of dv.getOwnPropertyNames()) {
68 var v = dv.getOwnPropertyDescriptor(name).value;
69 if (v instanceof Debugger.Object) {
77 function debuggeeValueToString(dv, style) {
78 var dvrepr = dvToString(dv);
79 if (!style.pretty || (typeof dv !== 'object'))
80 return [dvrepr, undefined];
82 if (dv.class == "Error") {
83 let errval = debuggeeGlobalWrapper.executeInGlobalWithBindings("$$.toString()", debuggeeValues);
84 return [dvrepr, errval.return];
88 return [dvrepr, JSON.stringify(summaryObject(dv), null, 4)];
90 let str = debuggeeGlobalWrapper.executeInGlobalWithBindings("JSON.stringify(v, null, 4)", {v: dv});
93 return [dvrepr, undefined];
96 Object.assign(substyle, style);
97 substyle.noerror = true;
98 return [dvrepr, debuggeeValueToString(str.throw, substyle)];
101 return [dvrepr, str.return];
104 // Problem! Used to do [object Object] followed by details. Now just details?
106 function showDebuggeeValue(dv, style={pretty: options.pretty}) {
107 var i = nextDebuggeeValueIndex++;
108 debuggeeValues["$" + i] = dv;
109 debuggeeValues["$$"] = dv;
110 let [brief, full] = debuggeeValueToString(dv, style);
111 print("$" + i + " = " + brief);
112 if (full !== undefined)
116 Object.defineProperty(Debugger.Frame.prototype, "num", {
121 for (var f = topFrame; f && f !== this; f = f.older)
123 return f === null ? undefined : i;
127 Debugger.Frame.prototype.frameDescription = function frameDescription() {
128 if (this.type == "call")
129 return ((this.callee.name || '<anonymous>') +
130 "(" + this.arguments.map(dvToString).join(", ") + ")");
132 return this.type + " code";
135 Debugger.Frame.prototype.positionDescription = function positionDescription() {
137 var line = this.script.getOffsetLocation(this.offset).lineNumber;
139 return this.script.url + ":" + line;
140 return "line " + line;
145 Debugger.Frame.prototype.location = function () {
147 var { lineNumber, columnNumber, isEntryPoint } = this.script.getOffsetLocation(this.offset);
149 return this.script.url + ":" + lineNumber;
155 Debugger.Frame.prototype.fullDescription = function fullDescription() {
156 var fr = this.frameDescription();
157 var pos = this.positionDescription();
159 return fr + ", " + pos;
163 Object.defineProperty(Debugger.Frame.prototype, "line", {
168 return this.script.getOffsetLocation(this.offset).lineNumber;
174 function callDescription(f) {
175 return ((f.callee.name || '<anonymous>') +
176 "(" + f.arguments.map(dvToString).join(", ") + ")");
179 function showFrame(f, n) {
180 if (f === undefined || f === null) {
187 if (n === undefined) {
190 throw new Error("Internal error: frame not on stack");
193 print('#' + n + " " + f.fullDescription());
196 function saveExcursion(fn) {
197 var tf = topFrame, ff = focusedFrame;
206 function parseArgs(str) {
207 return str.split(" ");
210 function describedRv(r, desc) {
211 desc = "[" + desc + "] ";
212 if (r === undefined) {
213 print(desc + "Returning undefined");
214 } else if (r === null) {
215 print(desc + "Returning null");
216 } else if (r.length === undefined) {
217 print(desc + "Returning object " + JSON.stringify(r));
219 print(desc + "Returning length-" + r.length + " list");
227 // Rerun the program (reloading it from the file)
228 function runCommand(args) {
229 print("Restarting program");
231 activeTask.scriptArgs = parseArgs(args);
233 for (var f = topFrame; f; f = f.older) {
235 f.onPop = () => null;
237 f.onPop = () => ({ 'return': 0 });
240 //return describedRv([{ 'return': 0 }], "runCommand");
244 // Evaluate an expression in the Debugger global
245 function evalCommand(expr) {
249 function quitCommand() {
250 dbg.removeAllDebuggees();
254 function backtraceCommand() {
255 if (topFrame === null)
257 for (var i = 0, f = topFrame; f; i++, f = f.older)
261 function setCommand(rest) {
262 var space = rest.indexOf(' ');
264 print("Invalid set <option> <value> command");
266 var name = rest.substr(0, space);
267 var value = rest.substr(space + 1);
269 if (name == 'args') {
270 activeTask.scriptArgs = parseArgs(value);
272 var yes = ["1", "yes", "true", "on"];
273 var no = ["0", "no", "false", "off"];
275 if (yes.includes(value))
276 options[name] = true;
277 else if (no.includes(value))
278 options[name] = false;
280 options[name] = value;
285 function split_print_options(s, style) {
286 var m = /^\/(\w+)/.exec(s);
289 if (m[1].includes("p"))
291 if (m[1].includes("b"))
293 return [ s.substr(m[0].length).trimLeft(), style ];
296 function doPrint(expr, style) {
297 // This is the real deal.
298 var cv = saveExcursion(
299 () => focusedFrame == null
300 ? debuggeeGlobalWrapper.executeInGlobalWithBindings(expr, debuggeeValues)
301 : focusedFrame.evalWithBindings(expr, debuggeeValues));
303 print("Debuggee died.");
304 } else if ('return' in cv) {
305 showDebuggeeValue(cv.return, style);
307 print("Exception caught. (To rethrow it, type 'throw'.)");
309 showDebuggeeValue(lastExc, style);
313 function printCommand(rest) {
314 var [expr, style] = split_print_options(rest, {pretty: options.pretty});
315 return doPrint(expr, style);
318 function keysCommand(rest) { return doPrint("Object.keys(" + rest + ")"); }
320 function detachCommand() {
321 dbg.removeAllDebuggees();
325 function continueCommand(rest) {
326 if (focusedFrame === null) {
331 var match = rest.match(/^(\d+)$/);
333 return doStepOrNext({upto:true, stopLine:match[1]});
339 function throwCommand(rest) {
341 if (focusedFrame !== topFrame) {
342 print("To throw, you must select the newest frame (use 'frame 0').");
344 } else if (focusedFrame === null) {
347 } else if (rest === '') {
348 return [{throw: lastExc}];
350 var cv = saveExcursion(function () { return focusedFrame.eval(rest); });
352 print("Debuggee died while determining what to throw. Stopped.");
353 } else if ('return' in cv) {
354 return [{throw: cv.return}];
356 print("Exception determining what to throw. Stopped.");
357 showDebuggeeValue(cv.throw);
363 function frameCommand(rest) {
365 if (rest.match(/[0-9]+/)) {
372 for (var i = 0; i < n && f; i++) {
374 print("There is no frame " + rest + ".");
381 updateLocation(focusedFrame);
383 } else if (rest === '') {
384 if (topFrame === null) {
387 updateLocation(focusedFrame);
391 print("do what now?");
395 function upCommand() {
396 if (focusedFrame === null)
398 else if (focusedFrame.older === null)
399 print("Initial frame selected; you cannot go up.");
401 focusedFrame.older.younger = focusedFrame;
402 focusedFrame = focusedFrame.older;
403 updateLocation(focusedFrame);
408 function downCommand() {
409 if (focusedFrame === null)
411 else if (!focusedFrame.younger)
412 print("Youngest frame selected; you cannot go down.");
414 focusedFrame = focusedFrame.younger;
415 updateLocation(focusedFrame);
420 function forcereturnCommand(rest) {
422 var f = focusedFrame;
423 if (f !== topFrame) {
424 print("To forcereturn, you must select the newest frame (use 'frame 0').");
425 } else if (f === null) {
426 print("Nothing on the stack.");
427 } else if (rest === '') {
428 return [{return: undefined}];
430 var cv = saveExcursion(function () { return f.eval(rest); });
432 print("Debuggee died while determining what to forcereturn. Stopped.");
433 } else if ('return' in cv) {
434 return [{return: cv.return}];
436 print("Error determining what to forcereturn. Stopped.");
437 showDebuggeeValue(cv.throw);
442 function printPop(f, c) {
443 var fdesc = f.fullDescription();
445 print("frame returning (still selected): " + fdesc);
446 showDebuggeeValue(c.return, {brief: true});
447 } else if (c.throw) {
448 print("frame threw exception: " + fdesc);
449 showDebuggeeValue(c.throw);
450 print("(To rethrow it, type 'throw'.)");
453 print("frame was terminated: " + fdesc);
457 // Set |prop| on |obj| to |value|, but then restore its current value
458 // when we next enter the repl.
459 function setUntilRepl(obj, prop, value) {
460 var saved = obj[prop];
462 replCleanups.push(function () { obj[prop] = saved; });
465 function updateLocation(frame) {
467 var loc = frame.location();
469 print("\032\032" + loc + ":1");
473 function doStepOrNext(kind) {
474 var startFrame = topFrame;
475 var startLine = startFrame.line;
476 // print("stepping in: " + startFrame.fullDescription());
477 // print("starting line: " + uneval(startLine));
479 function stepPopped(completion) {
480 // Note that we're popping this frame; we need to watch for
481 // subsequent step events on its caller.
482 this.reportedPop = true;
483 printPop(this, completion);
484 topFrame = focusedFrame = this;
486 // We want to continue, but this frame is going to be invalid as
487 // soon as this function returns, which will make the replCleanups
488 // assert when it tries to access the dead frame's 'onPop'
489 // property. So clear it out now while the frame is still valid,
490 // and trade it for an 'onStep' callback on the frame we're popping to.
492 setUntilRepl(this.older, 'onStep', stepStepped);
495 updateLocation(this);
499 function stepEntered(newFrame) {
500 print("entered frame: " + newFrame.fullDescription());
501 updateLocation(newFrame);
502 topFrame = focusedFrame = newFrame;
506 function stepStepped() {
507 // print("stepStepped: " + this.fullDescription());
508 updateLocation(this);
512 // 'finish' set a one-time onStep for stopping at the frame it
513 // wants to return to
515 } else if (kind.upto) {
516 // running until a given line is reached
517 if (this.line == kind.stopLine)
520 // regular step; stop whenever the line number changes
521 if ((this.line != startLine) || (this != startFrame))
526 topFrame = focusedFrame = this;
527 if (focusedFrame != startFrame)
528 print(focusedFrame.fullDescription());
532 // Otherwise, let execution continue.
537 setUntilRepl(dbg, 'onEnterFrame', stepEntered);
539 // If we're stepping after an onPop, watch for steps and pops in the
540 // next-older frame; this one is done.
541 var stepFrame = startFrame.reportedPop ? startFrame.older : startFrame;
542 if (!stepFrame || !stepFrame.script)
546 setUntilRepl(stepFrame, 'onStep', stepStepped);
547 setUntilRepl(stepFrame, 'onPop', stepPopped);
550 // Let the program continue!
554 function stepCommand() { return doStepOrNext({step:true}); }
555 function nextCommand() { return doStepOrNext({next:true}); }
556 function finishCommand() { return doStepOrNext({finish:true}); }
558 // FIXME: DOES NOT WORK YET
559 function breakpointCommand(where) {
560 print("Sorry, breakpoints don't work yet.");
561 var script = focusedFrame.script;
562 var offsets = script.getLineOffsets(Number(where));
563 if (offsets.length == 0) {
564 print("Unable to break at line " + where);
567 for (var offset of offsets) {
568 script.setBreakpoint(offset, { hit: handleBreakpoint });
570 print("Set breakpoint in " + script.url + ":" + script.startLine + " at line " + where + ", " + offsets.length);
573 // Build the table of commands.
576 backtraceCommand, "bt", "where",
577 breakpointCommand, "b", "break",
578 continueCommand, "c",
584 finishCommand, "fin",
596 var currentCmd = null;
597 for (var i = 0; i < commandArray.length; i++) {
598 var cmd = commandArray[i];
599 if (typeof cmd === "string")
600 commands[cmd] = currentCmd;
602 currentCmd = commands[cmd.name.replace(/Command$/, '')] = cmd;
605 function helpCommand(rest) {
606 print("Available commands:");
607 var printcmd = function(group) {
608 print(" " + group.join(", "));
612 for (var cmd of commandArray) {
613 if (typeof cmd === "string") {
616 if (group.length) printcmd(group);
617 group = [ cmd.name.replace(/Command$/, '') ];
623 // Break cmd into two parts: its first word and everything else. If it begins
624 // with punctuation, treat that as a separate word. The first word is
625 // terminated with whitespace or the '/' character. So:
627 // print x => ['print', 'x']
628 // print => ['print', '']
629 // !print x => ['!', 'print x']
630 // ?!wtf!? => ['?', '!wtf!?']
631 // print/b x => ['print', '/b x']
633 function breakcmd(cmd) {
634 cmd = cmd.trimLeft();
635 if ("!@#$%^&*_+=/?.,<>:;'\"".includes(cmd.substr(0, 1)))
636 return [cmd.substr(0, 1), cmd.substr(1).trimLeft()];
637 var m = /\s+|(?=\/)/.exec(cmd);
640 return [cmd.slice(0, m.index), cmd.slice(m.index + m[0].length)];
643 function runcmd(cmd) {
644 var pieces = breakcmd(cmd);
645 if (pieces[0] === "")
648 var first = pieces[0], rest = pieces[1];
649 if (!commands.hasOwnProperty(first)) {
650 print("unrecognized command '" + first + "'");
654 var cmd = commands[first];
655 if (cmd.length === 0 && rest !== '') {
656 print("this command cannot take an argument");
663 function preReplCleanups() {
664 while (replCleanups.length > 0)
665 replCleanups.pop()();
668 var prevcmd = undefined;
674 putstr("\n" + prompt);
683 var result = runcmd(cmd);
684 if (result === undefined)
685 ; // do nothing, return to prompt
686 else if (Array.isArray(result))
688 else if (result === null)
691 throw new Error("Internal error: result of runcmd wasn't array or undefined: " + result);
693 print("*** Internal error: exception in the debugger code.");
700 var dbg = new Debugger();
701 dbg.onDebuggerStatement = function (frame) {
702 return saveExcursion(function () {
703 topFrame = focusedFrame = frame;
704 print("'debugger' statement hit.");
706 updateLocation(focusedFrame);
708 return describedRv(repl(), "debugger.saveExc");
711 dbg.onThrow = function (frame, exc) {
712 return saveExcursion(function () {
713 topFrame = focusedFrame = frame;
714 print("Unwinding due to exception. (Type 'c' to continue unwinding.)");
716 print("Exception value is:");
717 showDebuggeeValue(exc);
722 function handleBreakpoint (frame) {
723 print("Breakpoint hit!");
724 return saveExcursion(() => {
725 topFrame = focusedFrame = frame;
726 print("breakpoint hit.");
728 updateLocation(focusedFrame);
733 // The depth of jorendb nesting.
735 if (typeof jorendbDepth == 'undefined') jorendbDepth = 0;
737 var debuggeeGlobal = newGlobal({newCompartment: true});
738 debuggeeGlobal.jorendbDepth = jorendbDepth + 1;
739 var debuggeeGlobalWrapper = dbg.addDebuggee(debuggeeGlobal);
741 print("jorendb version -0.0");
742 prompt = '(' + Array(jorendbDepth+1).join('meta-') + 'jorendb) ';
744 var args = scriptArgs.slice(0);
745 print("INITIAL ARGS: " + args);
747 // Find the script to run and its arguments. The script may have been given as
748 // a plain script name, in which case all remaining arguments belong to the
749 // script. Or there may have been any number of arguments to the JS shell,
750 // followed by -f scriptName, followed by additional arguments to the JS shell,
751 // followed by the script arguments. There may be multiple -e or -f options in
752 // the JS shell arguments, and we want to treat each one as a debuggable
755 // The difficulty is that the JS shell has a mixture of
763 // parameters, and there's no way to know whether --option takes an argument or
764 // not. We will assume that VAL will never end in .js, or rather that the first
765 // argument that does not start with "-" but does end in ".js" is the name of
768 // If you need to pass other options and not have them given to the script,
769 // pass them before the -f jorendb.js argument. Thus, the safe ways to pass
772 // js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)+ -- [script args]
773 // js [JS shell options] -f jorendb.js (-e SCRIPT | -f FILE)* script.js [script args]
775 // Additionally, if you want to run a script that is *NOT* debugged, put it in
776 // as part of the leading [JS shell options].
779 // Compute actualScriptArgs by finding the script to be run and grabbing every
780 // non-script argument. The script may be given by -f scriptname or just plain
781 // scriptname. In the latter case, it will be in the global variable
782 // 'scriptPath' (and NOT in scriptArgs.)
783 var actualScriptArgs = [];
786 if (scriptPath !== undefined) {
789 'script': scriptPath,
794 while(args.length > 0) {
795 var arg = args.shift();
796 print("arg: " + arg);
803 } else if (arg == '-f') {
804 var script = args.shift();
805 print(" load -f " + script);
811 } else if (arg.indexOf("-") == 0) {
813 print(" pass remaining args to script");
814 actualScriptArgs.push(...args);
816 } else if ((args.length > 0) && (args[0].indexOf(".js") + 3 == args[0].length)) {
817 // Ends with .js, assume we are looking at --boolean script.js
818 print(" load script.js after --boolean");
821 'script': args.shift(),
825 // Does not end with .js, assume we are looking at JS shell arg
832 print(" load general");
833 actualScriptArgs.push(...args);
840 print(" arg " + arg);
841 actualScriptArgs.push(arg);
845 print("jorendb: scriptPath = " + scriptPath);
846 print("jorendb: scriptArgs = " + scriptArgs);
847 print("jorendb: actualScriptArgs = " + actualScriptArgs);
849 for (var task of todo) {
850 task['scriptArgs'] = actualScriptArgs;
853 // Always drop into a repl at the end. Especially if the main script throws an
855 todo.push({ 'action': 'repl' });
858 print("Top of run loop");
860 for (var task of todo) {
862 if (task.action == 'eval') {
863 debuggeeGlobal.eval(task.code);
864 } else if (task.action == 'load') {
865 debuggeeGlobal['scriptArgs'] = task.scriptArgs;
866 debuggeeGlobal['scriptPath'] = task.script;
867 print("Loading JavaScript file " + task.script);
869 debuggeeGlobal.evaluate(read(task.script), { 'fileName': task.script, 'lineNumber': 1 });
871 print("Caught exception " + exc);
874 } else if (task.action == 'repl') {