diff options
Diffstat (limited to 'devtools/shared/webconsole/js-property-provider.js')
-rw-r--r-- | devtools/shared/webconsole/js-property-provider.js | 538 |
1 files changed, 538 insertions, 0 deletions
diff --git a/devtools/shared/webconsole/js-property-provider.js b/devtools/shared/webconsole/js-property-provider.js new file mode 100644 index 000000000..9ada46732 --- /dev/null +++ b/devtools/shared/webconsole/js-property-provider.js @@ -0,0 +1,538 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft= javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +if (!isWorker) { + loader.lazyImporter(this, "Parser", "resource://devtools/shared/Parser.jsm"); +} + +// Provide an easy way to bail out of even attempting an autocompletion +// if an object has way too many properties. Protects against large objects +// with numeric values that wouldn't be tallied towards MAX_AUTOCOMPLETIONS. +const MAX_AUTOCOMPLETE_ATTEMPTS = exports.MAX_AUTOCOMPLETE_ATTEMPTS = 100000; +// Prevent iterating over too many properties during autocomplete suggestions. +const MAX_AUTOCOMPLETIONS = exports.MAX_AUTOCOMPLETIONS = 1500; + +const STATE_NORMAL = 0; +const STATE_QUOTE = 2; +const STATE_DQUOTE = 3; + +const OPEN_BODY = "{[(".split(""); +const CLOSE_BODY = "}])".split(""); +const OPEN_CLOSE_BODY = { + "{": "}", + "[": "]", + "(": ")", +}; + +function hasArrayIndex(str) { + return /\[\d+\]$/.test(str); +} + +/** + * Analyses a given string to find the last statement that is interesting for + * later completion. + * + * @param string str + * A string to analyse. + * + * @returns object + * If there was an error in the string detected, then a object like + * + * { err: "ErrorMesssage" } + * + * is returned, otherwise a object like + * + * { + * state: STATE_NORMAL|STATE_QUOTE|STATE_DQUOTE, + * startPos: index of where the last statement begins + * } + */ +function findCompletionBeginning(str) { + let bodyStack = []; + + let state = STATE_NORMAL; + let start = 0; + let c; + for (let i = 0; i < str.length; i++) { + c = str[i]; + + switch (state) { + // Normal JS state. + case STATE_NORMAL: + if (c == '"') { + state = STATE_DQUOTE; + } else if (c == "'") { + state = STATE_QUOTE; + } else if (c == ";") { + start = i + 1; + } else if (c == " ") { + start = i + 1; + } else if (OPEN_BODY.indexOf(c) != -1) { + bodyStack.push({ + token: c, + start: start + }); + start = i + 1; + } else if (CLOSE_BODY.indexOf(c) != -1) { + let last = bodyStack.pop(); + if (!last || OPEN_CLOSE_BODY[last.token] != c) { + return { + err: "syntax error" + }; + } + if (c == "}") { + start = i + 1; + } else { + start = last.start; + } + } + break; + + // Double quote state > " < + case STATE_DQUOTE: + if (c == "\\") { + i++; + } else if (c == "\n") { + return { + err: "unterminated string literal" + }; + } else if (c == '"') { + state = STATE_NORMAL; + } + break; + + // Single quote state > ' < + case STATE_QUOTE: + if (c == "\\") { + i++; + } else if (c == "\n") { + return { + err: "unterminated string literal" + }; + } else if (c == "'") { + state = STATE_NORMAL; + } + break; + } + } + + return { + state: state, + startPos: start + }; +} + +/** + * Provides a list of properties, that are possible matches based on the passed + * Debugger.Environment/Debugger.Object and inputValue. + * + * @param object dbgObject + * When the debugger is not paused this Debugger.Object wraps + * the scope for autocompletion. + * It is null if the debugger is paused. + * @param object anEnvironment + * When the debugger is paused this Debugger.Environment is the + * scope for autocompletion. + * It is null if the debugger is not paused. + * @param string inputValue + * Value that should be completed. + * @param number [cursor=inputValue.length] + * Optional offset in the input where the cursor is located. If this is + * omitted then the cursor is assumed to be at the end of the input + * value. + * @returns null or object + * If no completion valued could be computed, null is returned, + * otherwise a object with the following form is returned: + * { + * matches: [ string, string, string ], + * matchProp: Last part of the inputValue that was used to find + * the matches-strings. + * } + */ +function JSPropertyProvider(dbgObject, anEnvironment, inputValue, cursor) { + if (cursor === undefined) { + cursor = inputValue.length; + } + + inputValue = inputValue.substring(0, cursor); + + // Analyse the inputValue and find the beginning of the last part that + // should be completed. + let beginning = findCompletionBeginning(inputValue); + + // There was an error analysing the string. + if (beginning.err) { + return null; + } + + // If the current state is not STATE_NORMAL, then we are inside of an string + // which means that no completion is possible. + if (beginning.state != STATE_NORMAL) { + return null; + } + + let completionPart = inputValue.substring(beginning.startPos); + let lastDot = completionPart.lastIndexOf("."); + + // Don't complete on just an empty string. + if (completionPart.trim() == "") { + return null; + } + + // Catch literals like [1,2,3] or "foo" and return the matches from + // their prototypes. + // Don't run this is a worker, migrating to acorn should allow this + // to run in a worker - Bug 1217198. + if (!isWorker && lastDot > 0) { + let parser = new Parser(); + parser.logExceptions = false; + let syntaxTree = parser.get(completionPart.slice(0, lastDot)); + let lastTree = syntaxTree.getLastSyntaxTree(); + let lastBody = lastTree && lastTree.AST.body[lastTree.AST.body.length - 1]; + + // Finding the last expression since we've sliced up until the dot. + // If there were parse errors this won't exist. + if (lastBody) { + let expression = lastBody.expression; + let matchProp = completionPart.slice(lastDot + 1); + if (expression.type === "ArrayExpression") { + return getMatchedProps(Array.prototype, matchProp); + } else if (expression.type === "Literal" && + (typeof expression.value === "string")) { + return getMatchedProps(String.prototype, matchProp); + } + } + } + + // We are completing a variable / a property lookup. + let properties = completionPart.split("."); + let matchProp = properties.pop().trimLeft(); + let obj = dbgObject; + + // The first property must be found in the environment of the paused debugger + // or of the global lexical scope. + let env = anEnvironment || obj.asEnvironment(); + + if (properties.length === 0) { + return getMatchedPropsInEnvironment(env, matchProp); + } + + let firstProp = properties.shift().trim(); + if (firstProp === "this") { + // Special case for 'this' - try to get the Object from the Environment. + // No problem if it throws, we will just not autocomplete. + try { + obj = env.object; + } catch (e) { + // Ignore. + } + } else if (hasArrayIndex(firstProp)) { + obj = getArrayMemberProperty(null, env, firstProp); + } else { + obj = getVariableInEnvironment(env, firstProp); + } + + if (!isObjectUsable(obj)) { + return null; + } + + // We get the rest of the properties recursively starting from the + // Debugger.Object that wraps the first property + for (let i = 0; i < properties.length; i++) { + let prop = properties[i].trim(); + if (!prop) { + return null; + } + + if (hasArrayIndex(prop)) { + // The property to autocomplete is a member of array. For example + // list[i][j]..[n]. Traverse the array to get the actual element. + obj = getArrayMemberProperty(obj, null, prop); + } else { + obj = DevToolsUtils.getProperty(obj, prop); + } + + if (!isObjectUsable(obj)) { + return null; + } + } + + // If the final property is a primitive + if (typeof obj != "object") { + return getMatchedProps(obj, matchProp); + } + + return getMatchedPropsInDbgObject(obj, matchProp); +} + +/** + * Get the array member of obj for the given prop. For example, given + * prop='list[0][1]' the element at [0][1] of obj.list is returned. + * + * @param object obj + * The object to operate on. Should be null if env is passed. + * @param object env + * The Environment to operate in. Should be null if obj is passed. + * @param string prop + * The property to return. + * @return null or Object + * Returns null if the property couldn't be located. Otherwise the array + * member identified by prop. + */ +function getArrayMemberProperty(obj, env, prop) { + // First get the array. + let propWithoutIndices = prop.substr(0, prop.indexOf("[")); + + if (env) { + obj = getVariableInEnvironment(env, propWithoutIndices); + } else { + obj = DevToolsUtils.getProperty(obj, propWithoutIndices); + } + + if (!isObjectUsable(obj)) { + return null; + } + + // Then traverse the list of indices to get the actual element. + let result; + let arrayIndicesRegex = /\[[^\]]*\]/g; + while ((result = arrayIndicesRegex.exec(prop)) !== null) { + let indexWithBrackets = result[0]; + let indexAsText = indexWithBrackets.substr(1, indexWithBrackets.length - 2); + let index = parseInt(indexAsText, 10); + + if (isNaN(index)) { + return null; + } + + obj = DevToolsUtils.getProperty(obj, index); + + if (!isObjectUsable(obj)) { + return null; + } + } + + return obj; +} + +/** + * Check if the given Debugger.Object can be used for autocomplete. + * + * @param Debugger.Object object + * The Debugger.Object to check. + * @return boolean + * True if further inspection into the object is possible, or false + * otherwise. + */ +function isObjectUsable(object) { + if (object == null) { + return false; + } + + if (typeof object == "object" && object.class == "DeadObject") { + return false; + } + + return true; +} + +/** + * @see getExactMatchImpl() + */ +function getVariableInEnvironment(anEnvironment, name) { + return getExactMatchImpl(anEnvironment, name, DebuggerEnvironmentSupport); +} + +/** + * @see getMatchedPropsImpl() + */ +function getMatchedPropsInEnvironment(anEnvironment, match) { + return getMatchedPropsImpl(anEnvironment, match, DebuggerEnvironmentSupport); +} + +/** + * @see getMatchedPropsImpl() + */ +function getMatchedPropsInDbgObject(dbgObject, match) { + return getMatchedPropsImpl(dbgObject, match, DebuggerObjectSupport); +} + +/** + * @see getMatchedPropsImpl() + */ +function getMatchedProps(obj, match) { + if (typeof obj != "object") { + obj = obj.constructor.prototype; + } + return getMatchedPropsImpl(obj, match, JSObjectSupport); +} + +/** + * Get all properties in the given object (and its parent prototype chain) that + * match a given prefix. + * + * @param mixed obj + * Object whose properties we want to filter. + * @param string match + * Filter for properties that match this string. + * @return object + * Object that contains the matchProp and the list of names. + */ +function getMatchedPropsImpl(obj, match, {chainIterator, getProperties}) { + let matches = new Set(); + let numProps = 0; + + // We need to go up the prototype chain. + let iter = chainIterator(obj); + for (obj of iter) { + let props = getProperties(obj); + numProps += props.length; + + // If there are too many properties to event attempt autocompletion, + // or if we have already added the max number, then stop looping + // and return the partial set that has already been discovered. + if (numProps >= MAX_AUTOCOMPLETE_ATTEMPTS || + matches.size >= MAX_AUTOCOMPLETIONS) { + break; + } + + for (let i = 0; i < props.length; i++) { + let prop = props[i]; + if (prop.indexOf(match) != 0) { + continue; + } + if (prop.indexOf("-") > -1) { + continue; + } + // If it is an array index, we can't take it. + // This uses a trick: converting a string to a number yields NaN if + // the operation failed, and NaN is not equal to itself. + if (+prop != +prop) { + matches.add(prop); + } + + if (matches.size >= MAX_AUTOCOMPLETIONS) { + break; + } + } + } + + return { + matchProp: match, + matches: [...matches], + }; +} + +/** + * Returns a property value based on its name from the given object, by + * recursively checking the object's prototype. + * + * @param object obj + * An object to look the property into. + * @param string name + * The property that is looked up. + * @returns object|undefined + * A Debugger.Object if the property exists in the object's prototype + * chain, undefined otherwise. + */ +function getExactMatchImpl(obj, name, {chainIterator, getProperty}) { + // We need to go up the prototype chain. + let iter = chainIterator(obj); + for (obj of iter) { + let prop = getProperty(obj, name, obj); + if (prop) { + return prop.value; + } + } + return undefined; +} + +var JSObjectSupport = { + chainIterator: function* (obj) { + while (obj) { + yield obj; + obj = Object.getPrototypeOf(obj); + } + }, + + getProperties: function (obj) { + return Object.getOwnPropertyNames(obj); + }, + + getProperty: function () { + // getProperty is unsafe with raw JS objects. + throw new Error("Unimplemented!"); + }, +}; + +var DebuggerObjectSupport = { + chainIterator: function* (obj) { + while (obj) { + yield obj; + obj = obj.proto; + } + }, + + getProperties: function (obj) { + return obj.getOwnPropertyNames(); + }, + + getProperty: function (obj, name, rootObj) { + // This is left unimplemented in favor to DevToolsUtils.getProperty(). + throw new Error("Unimplemented!"); + }, +}; + +var DebuggerEnvironmentSupport = { + chainIterator: function* (obj) { + while (obj) { + yield obj; + obj = obj.parent; + } + }, + + getProperties: function (obj) { + let names = obj.names(); + + // Include 'this' in results (in sorted order) + for (let i = 0; i < names.length; i++) { + if (i === names.length - 1 || names[i + 1] > "this") { + names.splice(i + 1, 0, "this"); + break; + } + } + + return names; + }, + + getProperty: function (obj, name) { + let result; + // Try/catch since name can be anything, and getVariable throws if + // it's not a valid ECMAScript identifier name + try { + // TODO: we should use getVariableDescriptor() here - bug 725815. + result = obj.getVariable(name); + } catch (e) { + // Ignore. + } + + // FIXME: Need actual UI, bug 941287. + if (result === undefined || result.optimizedOut || + result.missingArguments) { + return null; + } + return { value: result }; + }, +}; + +exports.JSPropertyProvider = DevToolsUtils.makeInfallible(JSPropertyProvider); + +// Export a version that will throw (for tests) +exports.FallibleJSPropertyProvider = JSPropertyProvider; |