// Copyright (c) 2008-2015 Kris Maglione // // This work is licensed for reuse under an MIT license. Details are // given in the LICENSE.txt file included with this file. "use strict"; try { defineModule("javascript", { exports: ["JavaScript", "javascript"], require: ["util"] }); lazyRequire("template", ["template"]); let isPrototypeOf = Object.prototype.isPrototypeOf; // TODO: Clean this up. var JavaScript = Module("javascript", { init: function () { this._stack = []; this._functions = []; this._top = {}; // The element on the top of the stack. this._last = ""; // The last opening char pushed onto the stack. this._lastNonwhite = ""; // Last non-whitespace character we saw. this._lastChar = ""; // Last character we saw, used for \ escaping quotes. this._str = ""; this._lastIdx = 0; this._cacheKey = null; this._nullSandbox = Cu.Sandbox("about:blank"); }, Local: function (dactyl, modules, window) { return { init: function init() { this.modules = modules; this.window = window; init.supercall(this); } }; }, globals: Class.Memoize(function () { return [ [this.modules.userContext, /*L*/"Global Variables"], [this.modules, "modules"], [this.window, "window"] ]; }), toplevel: Class.Memoize(function () { return this.modules.jsmodules; }), lazyInit: true, newContext: function () { return this.modules.newContext(this.modules.userContext, false, "Dactyl JS Temp Context"); }, completers: Class.Memoize(() => Object.create(JavaScript.completers)), // Some object members are only accessible as function calls getKey: function (obj, key) { try { return obj[key]; } catch (e) {} return undefined; }, iter: function* iter_(obj, toplevel) { if (obj == null) return; let seen = new RealSet(isinstance(obj, ["Sandbox"]) ? JavaScript.magicalNames : []); let globals = values(toplevel && this.window === obj ? this.globalNames : []); if (toplevel && isObject(obj) && "wrappedJSObject" in obj) if (!seen.add("wrappedJSObject")) yield "wrappedJSObject"; for (let key of iter(globals, properties(obj, !toplevel))) if (!seen.add(key)) yield key; // Properties aren't visible in an XPCNativeWrapper until // they're accessed. for (let key of properties(this.getKey(obj, "wrappedJSObject"), !toplevel)) try { if (key in obj && !seen.has(key)) yield key; } catch (e) {} }, objectKeys: function objectKeys(obj, toplevel) { // Things we can dereference if (!obj || ["object", "string", "function"].indexOf(typeof obj) === -1) return []; if (isinstance(obj, ["Sandbox"]) && !toplevel) // Temporary hack. return []; if (isPrototypeOf.call(this.toplevel, obj) && !toplevel) return []; let completions = Array.from(this.iter(obj, toplevel)); if (obj === this.modules) // Hack. completions = [...new RealSet([...completions, ...this.iter(this.modules.jsmodules, toplevel)])]; return completions; }, evalled: function evalled(arg, key, tmp) { let cache = this.context.cache.evalled; let context = this.context.cache.evalContext; if (!key) key = arg; if (key in cache) return cache[key]; context[JavaScript.EVAL_TMP] = tmp; try { cache[key] = this.modules.dactyl.userEval(arg, context, /*L*/"[Command Line Completion]", 1); return cache[key]; } catch (e) { util.reportError(e); this.context.message = _("error.error", e); return null; } finally { delete context[JavaScript.EVAL_TMP]; } }, // Get an element from the stack. If @frame is negative, // count from the top of the stack, otherwise, the bottom. // If @nth is provided, return the @mth value of element @type // of the stack entry at @frame. _get: function (frame, nth, type) { let a = this._stack[frame >= 0 ? frame : this._stack.length + frame]; if (type != null) a = a[type]; if (nth == null) return a; return a[a.length - nth - 1]; }, // Push and pop the stack, maintaining references to 'top' and 'last'. _push: function push(arg) { this._top = { offset: this._i, char: arg, statements: [this._i], dots: [], fullStatements: [], comma: [], functions: [] }; this._last = this._top.char; this._stack.push(this._top); }, _pop: function pop(arg) { if (this._i == this.context.caret - 1) this.context.highlight(this._top.offset, 1, "FIND"); if (this._top.char != arg) { this.context.highlight(this._top.offset, this._i - this._top.offset, "SPELLCHECK"); throw Error(/*L*/"Invalid JS"); } // The closing character of this stack frame will have pushed a new // statement, leaving us with an empty statement. This doesn't matter, // now, as we simply throw away the frame when we pop it, but it may later. if (this._top.statements[this._top.statements.length - 1] == this._i) this._top.statements.pop(); this._top = this._get(-2); this._last = this._top.char; return this._stack.pop(); }, _buildStack: function (filter) { // Todo: Fix these one-letter variable names. this._i = 0; this._c = ""; // Current index and character, respectively. // Reuse the old stack. if (this._str && filter.substr(0, this._str.length) == this._str) { this.context.highlight(0, 0, "FIND"); this._i = this._str.length; if (this.popStatement) this._top.statements.pop(); } else { this.context.highlight(); this._stack = []; this._functions = []; this._push("#root"); } // Build a parse stack, discarding entries as opening characters // match closing characters. The stack is walked from the top entry // and down as many levels as it takes us to figure out what it is // that we're completing. this._str = filter; let length = this._str.length; for (; this._i < length; this._lastChar = this._c, this._i++) { this._c = this._str[this._i]; if (/['"\/]/.test(this._last)) { if (this._lastChar == "\\") { // Escape. Skip the next char, whatever it may be. this._c = ""; this._i++; } else if (this._c == this._last) this._pop(this._c); } else { // A word character following a non-word character, or simply a non-word // character. Start a new statement. if (/[a-zA-Z_$]/.test(this._c) && !/[\w$]/.test(this._lastChar) || !/[\w\s$]/.test(this._c)) this._top.statements.push(this._i); // A "." or a "[" dereferences the last "statement" and effectively // joins it to this logical statement. if ((this._c == "." || this._c == "[") && /[\w$\])"']/.test(this._lastNonwhite) || this._lastNonwhite == "." && /[a-zA-Z_$]/.test(this._c)) this._top.statements.pop(); switch (this._c) { case "(": // Function call, or if/while/for/... if (/[\w$]/.test(this._lastNonwhite)) { this._functions.push(this._i); this._top.functions.push(this._i); this._top.statements.pop(); } case '"': case "'": case "/": case "{": case "[": this._push(this._c); break; case ".": this._top.dots.push(this._i); break; case ")": this._pop("("); break; case "]": this._pop("["); break; case "}": this._pop("{"); // Fallthrough case ";": this._top.fullStatements.push(this._i); break; case ",": this._top.comma.push(this._i); break; } if (/\S/.test(this._c)) this._lastNonwhite = this._c; } } this.popStatement = false; if (!/[\w$]/.test(this._lastChar) && this._lastNonwhite != ".") { this.popStatement = true; this._top.statements.push(this._i); } this._lastIdx = this._i; }, // Don't eval any function calls unless the user presses tab. _checkFunction: function (start, end, key) { let res = this._functions.some(idx => (idx >= start && idx < end)); if (!res || this.context.tabPressed || key in this.cache.evalled) return false; this.context.waitingForTab = true; return true; }, // For each DOT in a statement, prefix it with TMP, eval it, // and save the result back to TMP. The point of this is to // cache the entire path through an object chain, mainly in // the presence of function calls. There are drawbacks. For // instance, if the value of a variable changes in the course // of inputting a command (let foo=bar; frob(foo); foo=foo.bar; ...), // we'll still use the old value. But, it's worth it. _getObj: function (frame, stop) { let statement = this._get(frame, 0, "statements") || 0; // Current statement. let prev = statement; let obj = this.window; let cacheKey; for (let dot of this._get(frame).dots.concat(stop)) { if (dot < statement) continue; if (dot > stop || dot <= prev) break; let s = this._str.substring(prev, dot); if (prev != statement) s = JavaScript.EVAL_TMP + "." + s; cacheKey = this._str.substring(statement, dot); if (this._checkFunction(prev, dot, cacheKey)) return []; if (prev != statement && obj == null) { this.context.message = /*L*/"Error: " + JSON.stringify(cacheKey) + " is " + String(obj); return []; } prev = dot + 1; obj = this.evalled(s, cacheKey, obj); } return [[obj, cacheKey]]; }, _getObjKey: function (frame) { let dot = this._get(frame, 0, "dots") || -1; // Last dot in frame. let statement = this._get(frame, 0, "statements") || 0; // Current statement. let end = (frame == -1 ? this._lastIdx : this._get(frame + 1).offset); this._cacheKey = null; let obj = [[this.cache.evalContext, /*L*/"Local Variables"]].concat(this.globals); // Is this an object dereference? if (dot < statement) // No. dot = statement - 1; else // Yes. Set the object to the string before the dot. obj = this._getObj(frame, dot); let [, space, key] = this._str.substring(dot + 1, end).match(/^(\s*)(.*)/); return [dot + 1 + space.length, obj, key]; }, _complete: function (objects, key, compl, string, last) { const self = this; let base = this.context.fork("js", this._top.offset); base.forceAnchored = true; base.filter = last == null ? key : string; let prefix = last != null ? key : ""; if (last == null) // We're not looking for a quoted string, so filter out anything that's not a valid identifier base.filters.push(item => /^[a-zA-Z_$][\w$]*$/.test(item.text)); else { base.quote = [last, text => util.escapeString(text, ""), last]; if (prefix) base.filters.push(item => item.item.startsWith(prefix)); } if (!compl) { base.process[1] = function highlight(item, v) { return template.highlight(typeof v == "xml" ? new String(v.toXMLString()) : v, true, 200); }; // Sort in a logical fashion for object keys: // Numbers are sorted as numbers, rather than strings, and appear first. // Constants are unsorted, and appear before other non-null strings. // Other strings are sorted in the default manner. let isnan = function isnan(item) { return item != '' && isNaN(item); }; let compare = base.compare; base.compare = function (a, b) { if (!isnan(a.key) && !isnan(b.key)) return a.key - b.key; return isnan(b.key) - isnan(a.key) || compare(a, b); }; base.keys = { text: prefix ? text => text.substr(prefix.length) : text => text, description: function (item) { return self.getKey(this.obj, item); }, key: function (item) { if (!isNaN(key)) return parseInt(key); if (/^[A-Z_][A-Z0-9_]*$/.test(key)) return ""; return item; } }; } // We've already listed anchored matches, so don't list them again here. function unanchored(item) { return util.compareIgnoreCase(item.text.substr(0, this.filter.length), this.filter); } objects.forEach(function (obj) { let context = base.fork(obj[1]); context.title = [obj[1]]; context.keys.obj = () => obj[0]; context.key = obj[1] + last; if (obj[0] == this.cache.evalContext) context.regenerate = true; obj.ctxt_t = context.fork("toplevel"); if (!compl) { obj.ctxt_p = context.fork("prototypes"); obj.ctxt_t.generate = () => self.objectKeys(obj[0], true); obj.ctxt_p.generate = () => self.objectKeys(obj[0], false); } }, this); // TODO: Make this a generic completion helper function. objects.forEach(function (obj) { obj.ctxt_t.split(obj[1] + "/anchored", this, context => { context.anchored = true; if (compl) compl(context, obj[0]); }); }); if (compl) return; objects.forEach(function (obj) { obj.ctxt_p.split(obj[1] + "/anchored", this, context => { context.anchored = true; context.title[0] += /*L*/" (prototypes)"; }); }); objects.forEach(function (obj) { obj.ctxt_t.split(obj[1] + "/unanchored", this, context => { context.anchored = false; context.title[0] += /*L*/" (substrings)"; context.filters.push(unanchored); }); }); objects.forEach(function (obj) { obj.ctxt_p.split(obj[1] + "/unanchored", this, context => { context.anchored = false; context.title[0] += /*L*/" (prototype substrings)"; context.filters.push(unanchored); }); }); }, _getKey: function () { if (this._last == "") return ""; // After the opening [ upto the opening ", plus '' to take care of any operators before it let key = this._str.substring(this._get(-2, null, "offset") + 1, this._get(-1, null, "offset")) + "''"; // Now eval the key, to process any referenced variables. return this.evalled(key); }, get cache() { return this.context.cache; }, complete: function _complete(context) { const self = this; this.context = context; try { this._buildStack.call(this, context.filter); } catch (e) { this._lastIdx = 0; util.assert(!e.message, e.message); return null; } this.context.getCache("evalled", Object); this.context.getCache("evalContext", this.bound.newContext); // Okay, have parse stack. Figure out what we're completing. // Find any complete statements that we can eval before we eval our object. // This allows for things like: // let doc = content.document; let elem = doc.createEle ... let prev = 0; for (let v of this._get(0).fullStatements) { let key = this._str.substring(prev, v + 1); if (this._checkFunction(prev, v, key)) return null; this.evalled(key); prev = v + 1; } // If this is a function argument, try to get the function's // prototype and show it. try { let i = (this._get(-2) && this._get(-2).char == "(") ? -2 : -1; if (this._get(i).char == "(") { let [offset, obj, funcName] = this._getObjKey(i - 1); if (obj.length) { let func = obj[0][0][funcName]; if (callable(func)) { let [, prefix, args] = /^(function .*?)\((.*?)\)/.exec(Function.prototype.toString.call(func)); let n = this._get(i).comma.length; args = template.map(args.split(", ").entries(), ([i, arg]) => ["span", { highlight: i == n ? "Filter" : "" }, arg], ",\u00a0"); this.context.message = ["", prefix + "(", args, ")"]; } } } } catch (e) {} // In a string. Check if we're dereferencing an object or // completing a function argument. Otherwise, do nothing. if (this._last == "'" || this._last == '"') { // str = "foo[bar + 'baz" // obj = "foo" // key = "bar + ''" // The top of the stack is the sting we're completing. // Wrap it in its delimiters and eval it to process escape sequences. let string = this._str.substring(this._get(-1).offset + 1, this._lastIdx).replace(/((?:\\\\)*)\\/, "$1"); string = Cu.evalInSandbox(this._last + string + this._last, this._nullSandbox); // Is this an object accessor? if (this._get(-2).char == "[") { // Are we inside of []? // Stack: // [-1]: "... // [-2]: [... // [-3]: base statement // Yes. If the [ starts at the beginning of a logical // statement, we're in an array literal, and we're done. if (this._get(-3, 0, "statements") == this._get(-2).offset) return null; // Beginning of the statement upto the opening [ let obj = this._getObj(-3, this._get(-2).offset); return this._complete(obj, this._getKey(), null, string, this._last); } // Is this a function call? if (this._get(-2).char == "(") { // Stack: // [-1]: "... // [-2]: (... // [-3]: base statement // Does the opening "(" mark a function call? if (this._get(-3, 0, "functions") != this._get(-2).offset) return null; // No. We're done. let [offset, obj, funcName] = this._getObjKey(-3); if (!obj.length) return null; obj = obj.slice(0, 1); try { let func = obj[0][0][funcName]; var completer = func.dactylCompleter; } catch (e) {} if (!completer) completer = this.completers[funcName]; if (!completer) return null; // Split up the arguments let prev = this._get(-2).offset; let args = []; for (let [i, idx] of iter(this._get(-2).comma)) { let arg = this._str.substring(prev + 1, idx); prev = idx; memoize(args, i, () => self.evalled(arg)); } let key = this._getKey(); args.push(key + string); let compl = function (context, obj) { let res = completer.call(self, context, funcName, obj, args); if (res) context.completions = res; }; obj[0][1] += "." + funcName + "(... [" + args.length + "]"; return this._complete(obj, key, compl, string, this._last); } // In a string that's not an obj key or a function arg. // Nothing to do. return null; } // str = "foo.bar.baz" // obj = "foo.bar" // key = "baz" // // str = "foo" // obj = [modules, window] // key = "foo" let [offset, obj, key] = this._getObjKey(-1); // Wait for a keypress before completing when there's no key if (!this.context.tabPressed && key == "" && obj.length > 1) { let message = this.context.message || ""; this.context.waitingForTab = true; this.context.message = ["", message, "\n", _("completion.waitingForKeyPress")]; return null; } if (!/^(?:[a-zA-Z_$][\w$]*)?$/.test(key)) return null; // Not a word. Forget it. Can this even happen? try { // FIXME var o = this._top.offset; this._top.offset = offset; return this._complete(obj, key); } finally { this._top.offset = o; } return null; }, magicalNames: Class.Memoize(function () { return Object.getOwnPropertyNames(Cu.Sandbox(this.window), true) .sort(); }), /** * A list of properties of the global object which are not * enumerable by any standard method. */ globalNames: Class.Memoize(function () { let interfaces = Object.keys(Ci); let names = new RealSet([ "Array", "ArrayBuffer", "AttributeName", "Audio", "Boolean", "Components", "CSSFontFaceStyleDecl", "CSSGroupRuleRuleList", "CSSNameSpaceRule", "CSSRGBColor", "CSSRect", "ComputedCSSStyleDeclaration", "Date", "Error", "EvalError", "File", "Float32Array", "Float64Array", "Function", "HTMLDelElement", "HTMLInsElement", "HTMLSpanElement", "Infinity", "InnerModalContentWindow", "InnerWindow", "Int16Array", "Int32Array", "Int8Array", "InternalError", "Iterator", "JSON", "KeyboardEvent", "Math", "NaN", "Namespace", "Number", "Object", "Proxy", "QName", "ROCSSPrimitiveValue", "RangeError", "ReferenceError", "RegExp", "StopIteration", "String", "SyntaxError", "TypeError", "URIError", "Uint16Array", "Uint32Array", "Uint8Array", "XML", "XMLHttpProgressEvent", "XMLList", "XMLSerializer", "XPCNativeWrapper", "XULControllers", "constructor", "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "eval", "isFinite", "isNaN", "isXMLName", "parseFloat", "parseInt", "undefined", "unescape", "uneval", ...interfaces.filter(key => /^nsIDOM/.test(key)).map(key => key.substr(6)), ...interfaces.filter(key => /^nsI/.test(key)).map(key => key.substr(3)), ...this.magicalNames, ]); return [...names].filter(key => key in this.window); }) }, { EVAL_TMP: "__dactyl_eval_tmp", /** * A map of argument completion functions for named methods. The * signature and specification of the completion function * are fairly complex and yet undocumented. * * @see JavaScript.setCompleter */ completers: {}, /** * Installs argument string completers for a set of functions. * The second argument is an array of functions (or null * values), each corresponding the argument of the same index. * Each provided completion function receives as arguments a * CompletionContext, the 'this' object of the method, and an * array of values for the preceding arguments. * * It is important to note that values in the arguments array * provided to the completers are lazily evaluated the first * time they are accessed, so they should be accessed * judiciously. * * @param {function|[function]} funcs The functions for which to * install the completers. * @param {[function]} completers An array of completer * functions. */ setCompleter: function (funcs, completers) { funcs = Array.concat(funcs); for (let func of funcs) { func.dactylCompleter = function (context, func, obj, args) { let completer = completers[args.length - 1]; if (!completer) return []; return completer.call(obj, context, obj, args); }; func.dactylCompleter.completers = completers; } return arguments[0]; } }, { init: function init(dactyl, modules, window) { init.superapply(this, arguments); modules.JavaScript = Class("JavaScript", JavaScript, { modules: modules, window: window }); }, completion: function (dactyl, modules, window) { update(modules.completion, { get javascript() { return modules.javascript.bound.complete; }, javascriptCompleter: JavaScript // Backwards compatibility }); }, modes: function initModes(dactyl, modules, window) { initModes.require("commandline"); const { modes } = modules; modes.addMode("REPL", { description: "JavaScript Read Eval Print Loop", bases: [modes.COMMAND_LINE], displayName: Class.Memoize(function () { return this.name; }) }); }, commandline: function initCommandLine(dactyl, modules, window) { const { Buffer, modes } = modules; var REPL = Class("REPL", { init: function init(context) { this.context = context; this.results = []; return new Proxy(this, { get(target, prop) { if (prop in target) return target[prop]; if (prop in Buffer) return Buffer[prop].bind(Buffer, target.rootNode); }, }); }, addOutput: function addOutput(js) { this.count++; try { var result = dactyl.userEval(js, this.context); var xml = result === undefined ? "" : util.objectToString(result, true); } catch (e) { util.reportError(e); result = e; if (e.fileName) e = util.fixURI(e.fileName) + ":" + e.lineNumber + ": " + e; xml = ["span", { highlight: "ErrorMsg" }, e]; } let prompt = "js" + this.count; Class.replaceProperty(this.context, prompt, result); let nodes = {}; this.rootNode.appendChild( DOM.fromJSON( [["div", { highlight: "REPL-E", key: "e" }, ["span", { highlight: "REPL-R" }, prompt, ">"], " ", js], ["div", { highlight: "REPL-P", key: "p" }, xml]], this.document, nodes)); this.rootNode.scrollTop += nodes.e.getBoundingClientRect().top - this.rootNode.getBoundingClientRect().top; }, count: 0, message: Class.Memoize(function () { DOM.fromJSON(["div", { highlight: "REPL", key: "rootNode" }], this.document, this); return this.rootNode; }), }); modules.CommandREPLMode = Class("CommandREPLMode", modules.CommandMode, { init: function init(context) { init.supercall(this); let sandbox = true || isinstance(context, ["Sandbox"]); this.context = modules.newContext(context, !sandbox, "Dactyl REPL Context"); this.js = modules.JavaScript(); this.js.replContext = this.context; this.js.newContext = () => modules.newContext(this.context, !sandbox, "Dactyl REPL Temp Context"); this.js.globals = [ [this.context, /*L*/"REPL Variables"], [context, /*L*/"REPL Global"] ].concat(this.js.globals.filter(([global]) => isPrototypeOf.call(global, context))); if (!isPrototypeOf.call(modules.jsmodules, context)) this.js.toplevel = context; if (!isPrototypeOf.call(window, context)) this.js.window = context; if (this.js.globals.slice(2).some(([global]) => global === context)) this.js.globals.splice(1); this.repl = REPL(this.context); }, open: function open(context) { modules.mow.echo(this.repl); this.widgets.message = null; open.superapply(this, arguments); this.updatePrompt(); }, complete: function complete(context) { context.fork("js", 0, this.js, "complete"); }, historyKey: "javascript", mode: modes.REPL, get completionList() { return this.widgets.statusbar.commandline.id; }, accept: function accept() { dactyl.trapErrors(function () { this.repl.addOutput(this.command); }, this); this.completions.cleanup(); this.history.save(); this.history.reset(); this.updatePrompt(); modules.mow.resize(); }, leave: function leave(params) { leave.superapply(this, arguments); if (!params.push) { modes.delay(function () { modes.pop(); }); Cu.nukeSandbox(this.context); } }, updatePrompt: function updatePrompt() { this.command = ""; this.prompt = ["REPL-R", "js" + (this.repl.count + 1) + "> "]; } }); }, commands: function initCommands(dactyl, modules, window) { const { commands } = modules; commands.add(["javas[cript]", "js"], "Evaluate a JavaScript string", function (args) { if (args[0] && !args.bang) dactyl.userEval(args[0]); else { modules.commandline; modules.CommandREPLMode(args[0] ? dactyl.userEval(args[0]) : modules.userContext) .open(); } }, { argCount: "?", bang: true, completer: function (context) { modules.completion.javascript(context); }, hereDoc: true, literal: 0 }); }, mappings: function initMappings(dactyl, modules, window) { const { mappings, modes } = modules; function bind(...args) { return apply(mappings, "add", [[modes.REPL]].concat(args)); } bind([""], "Accept the current input", function ({ self }) { self.accept(); }); bind([""], "Scroll down one line", function ({ self }) { self.repl.scrollVertical("lines", 1); }); bind([""], "Scroll up one line", function ({ self }) { self.repl.scrollVertical("lines", -1); }); bind([""], "Scroll down half a page", function ({ self }) { self.repl.scrollVertical("pages", .5); }); bind(["", ""], "Scroll down one page", function ({ self }) { self.repl.scrollVertical("pages", 1); }); bind([""], "Scroll up half a page", function ({ self }) { self.repl.scrollVertical("pages", -.5); }); bind(["", ""], "Scroll up half a page", function ({ self }) { self.repl.scrollVertical("pages", -1); }); } }); endModule(); } catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); } // vim: set fdm=marker sw=4 sts=4 ts=8 et ft=javascript: