Files
react-native-devtools-frontend/front_end/object_ui/JavaScriptAutocomplete.js
T
Joel Einbinder e0dcbceb5a DevTools: Use throwOnSideEffects in console autocomplete
This lets us give suggestions for expressions like: ({abc: 4 + 3}).

Change-Id: Ia46b2eadb7725753408eb694414344075d00153e
Reviewed-on: https://chromium-review.googlesource.com/1014308
Commit-Queue: Joel Einbinder <einbinder@chromium.org>
Reviewed-by: Andrey Lushnikov <lushnikov@chromium.org>
Cr-Original-Commit-Position: refs/heads/master@{#552547}
Cr-Mirrored-From: https://chromium.googlesource.com/chromium/src
Cr-Mirrored-Commit: 00226e5a08d7c0d45e972668f5e9cb5b8911836d
2018-04-21 01:49:53 +00:00

455 lines
17 KiB
JavaScript

// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
ObjectUI.JavaScriptAutocomplete = class {
constructor() {
/** @type {!Map<string, {date: number, value: !Promise<?Object>}>} */
this._expressionCache = new Map();
SDK.consoleModel.addEventListener(SDK.ConsoleModel.Events.CommandEvaluated, this._clearCache, this);
SDK.targetManager.addModelListener(
SDK.RuntimeModel, SDK.RuntimeModel.Events.ExecutionContextChanged, this._clearCache, this);
SDK.targetManager.addModelListener(
SDK.DebuggerModel, SDK.DebuggerModel.Events.DebuggerPaused, this._clearCache, this);
}
_clearCache() {
this._expressionCache.clear();
}
/**
* @param {string} fullText
* @param {string} query
* @param {boolean=} force
* @return {!Promise<!UI.SuggestBox.Suggestions>}
*/
async completionsForTextInCurrentContext(fullText, query, force) {
const trimmedText = fullText.trim();
const [mapCompletions, expressionCompletions] = await Promise.all(
[this._mapCompletions(trimmedText, query), this._completionsForExpression(trimmedText, query, force)]);
return mapCompletions.concat(expressionCompletions);
}
/**
* @param {string} text
* @param {string} query
* @return {!Promise<!UI.SuggestBox.Suggestions>}
*/
async _mapCompletions(text, query) {
const mapMatch = text.match(/\.\s*(get|set|delete)\s*\(\s*$/);
const executionContext = UI.context.flavor(SDK.ExecutionContext);
if (!executionContext || !mapMatch)
return [];
const expression = await Formatter.formatterWorkerPool().findLastExpression(text.substring(0, mapMatch.index));
if (!expression)
return [];
const result = await executionContext.evaluate(
{
expression: expression.baseExpression,
objectGroup: 'mapCompletion',
includeCommandLineAPI: true,
silent: true,
returnByValue: false,
generatePreview: false,
throwOnSideEffect: expression.possibleSideEffects,
timeout: expression.possibleSideEffects ? 500 : undefined
},
/* userGesture */ false, /* awaitPromise */ false);
if (result.error || !!result.exceptionDetails || result.object.subtype !== 'map')
return [];
const properties = await result.object.getOwnPropertiesPromise(false);
const internalProperties = properties.internalProperties || [];
const entriesProperty = internalProperties.find(property => property.name === '[[Entries]]');
if (!entriesProperty)
return [];
const keysObj = await entriesProperty.value.callFunctionJSONPromise(getEntries);
executionContext.runtimeModel.releaseObjectGroup('mapCompletion');
return gotKeys(Object.keys(keysObj));
/**
* @suppressReceiverCheck
* @this {!Array<{key:?, value:?}>}
* @return {!Object}
*/
function getEntries() {
const result = {__proto__: null};
for (let i = 0; i < this.length; i++) {
if (typeof this[i].key === 'string')
result[this[i].key] = true;
}
return result;
}
/**
* @param {!Array<string>} rawKeys
* @return {!UI.SuggestBox.Suggestions}
*/
function gotKeys(rawKeys) {
const caseSensitivePrefix = [];
const caseInsensitivePrefix = [];
const caseSensitiveAnywhere = [];
const caseInsensitiveAnywhere = [];
let quoteChar = '"';
if (query.startsWith('\''))
quoteChar = '\'';
let endChar = ')';
if (mapMatch[0].indexOf('set') !== -1)
endChar = ', ';
const sorter = rawKeys.length < 1000 ? String.naturalOrderComparator : undefined;
const keys = rawKeys.sort(sorter).map(key => quoteChar + key + quoteChar);
for (const key of keys) {
if (key.length < query.length)
continue;
if (query.length && key.toLowerCase().indexOf(query.toLowerCase()) === -1)
continue;
// Substitute actual newlines with newline characters. @see crbug.com/498421
const title = key.split('\n').join('\\n');
const text = title + endChar;
if (key.startsWith(query))
caseSensitivePrefix.push({text: text, title: title, priority: 4});
else if (key.toLowerCase().startsWith(query.toLowerCase()))
caseInsensitivePrefix.push({text: text, title: title, priority: 3});
else if (key.indexOf(query) !== -1)
caseSensitiveAnywhere.push({text: text, title: title, priority: 2});
else
caseInsensitiveAnywhere.push({text: text, title: title, priority: 1});
}
const suggestions =
caseSensitivePrefix.concat(caseInsensitivePrefix, caseSensitiveAnywhere, caseInsensitiveAnywhere);
if (suggestions.length)
suggestions[0].subtitle = Common.UIString('Keys');
return suggestions;
}
}
/**
* @param {string} fullText
* @param {string} query
* @param {boolean=} force
* @return {!Promise<!UI.SuggestBox.Suggestions>}
*/
async _completionsForExpression(fullText, query, force) {
const executionContext = UI.context.flavor(SDK.ExecutionContext);
if (!executionContext)
return [];
let expression;
if (fullText.endsWith('.') || fullText.endsWith('['))
expression = await Formatter.formatterWorkerPool().findLastExpression(fullText.substring(0, fullText.length - 1));
if (!expression) {
if (fullText.endsWith('.'))
return [];
expression = {baseExpression: '', possibleSideEffects: false};
}
const needsNoSideEffects = expression.possibleSideEffects;
const expressionString = expression.baseExpression;
const dotNotation = fullText.endsWith('.');
const bracketNotation = !!expressionString && fullText.endsWith('[');
// User is entering float value, do not suggest anything.
if ((expressionString && !isNaN(expressionString)) || (!expressionString && query && !isNaN(query)))
return [];
if (!query && !expressionString && !force)
return [];
const selectedFrame = executionContext.debuggerModel.selectedCallFrame();
let completionGroups;
const TEN_SECONDS = 10000;
let cache = this._expressionCache.get(expressionString);
if (cache && cache.date + TEN_SECONDS > Date.now()) {
completionGroups = await cache.value;
} else if (!expressionString && selectedFrame) {
cache = {date: Date.now(), value: completionsOnPause(selectedFrame)};
this._expressionCache.set(expressionString, cache);
completionGroups = await cache.value;
} else {
const resultPromise = executionContext.evaluate(
{
expression: expressionString,
objectGroup: 'completion',
includeCommandLineAPI: true,
silent: true,
returnByValue: false,
generatePreview: false,
throwOnSideEffect: needsNoSideEffects,
timeout: needsNoSideEffects ? 500 : undefined
},
/* userGesture */ false, /* awaitPromise */ false);
cache = {date: Date.now(), value: resultPromise.then(result => completionsOnGlobal.call(this, result))};
this._expressionCache.set(expressionString, cache);
completionGroups = await cache.value;
}
return this._receivedPropertyNames(
completionGroups.slice(0), dotNotation, bracketNotation, expressionString, query);
/**
* @this {ObjectUI.JavaScriptAutocomplete}
* @param {!SDK.RuntimeModel.EvaluationResult} result
* @return {!Promise<!Array<!ObjectUI.JavaScriptAutocomplete.CompletionGroup>>}
*/
async function completionsOnGlobal(result) {
if (result.error || !!result.exceptionDetails || !result.object)
return [];
let object = result.object;
while (object && object.type === 'object' && object.subtype === 'proxy') {
const properties = await object.getOwnPropertiesPromise(false /* generatePreview */);
const internalProperties = properties.internalProperties || [];
const target = internalProperties.find(property => property.name === '[[Target]]');
object = target ? target.value : null;
}
if (!object)
return [];
let completions = [];
if (object.type === 'object' || object.type === 'function') {
completions =
await object.callFunctionJSONPromise(getCompletions, [SDK.RemoteObject.toCallArgument(object.subtype)]) ||
[];
} else if (
object.type === 'string' || object.type === 'number' || object.type === 'boolean' ||
object.type === 'bigint') {
const evaluateResult = await executionContext.evaluate(
{
expression: '(' + getCompletions + ')("' + object.type + '")',
objectGroup: 'completion',
includeCommandLineAPI: false,
silent: true,
returnByValue: true,
generatePreview: false
},
/* userGesture */ false,
/* awaitPromise */ false);
if (evaluateResult.object && !evaluateResult.exceptionDetails)
completions = evaluateResult.object.value || [];
}
executionContext.runtimeModel.releaseObjectGroup('completion');
if (!expressionString) {
const globalNames = await executionContext.globalLexicalScopeNames();
// Merge lexical scope names with first completion group on global object: let a and let b should be in the same group.
if (completions.length)
completions[0].items = completions[0].items.concat(globalNames);
else
completions.push({items: globalNames.sort(), title: Common.UIString('Lexical scope variables')});
}
for (const group of completions) {
for (let i = 0; i < group.items.length; i++)
group.items[i] = group.items[i].replace(/\n/g, '\\n');
group.items.sort(group.items.length < 1000 ? this._itemComparator : undefined);
}
return completions;
/**
* @param {string=} type
* @return {!Object}
* @suppressReceiverCheck
* @this {Object}
*/
function getCompletions(type) {
let object;
if (type === 'string')
object = new String('');
else if (type === 'number')
object = new Number(0);
// Object-wrapped BigInts cannot be constructed via `new BigInt`.
else if (type === 'bigint')
object = Object(BigInt(0));
else if (type === 'boolean')
object = new Boolean(false);
else
object = this;
const result = [];
try {
for (let o = object; o; o = Object.getPrototypeOf(o)) {
if ((type === 'array' || type === 'typedarray') && o === object && o.length > 9999)
continue;
const group = {items: [], __proto__: null};
try {
if (typeof o === 'object' && o.constructor && o.constructor.name)
group.title = o.constructor.name;
} catch (ee) {
// we could break upon cross origin check.
}
result[result.length] = group;
const names = Object.getOwnPropertyNames(o);
const isArray = Array.isArray(o);
for (let i = 0; i < names.length && group.items.length < 10000; ++i) {
// Skip array elements indexes.
if (isArray && /^[0-9]/.test(names[i]))
continue;
group.items[group.items.length] = names[i];
}
}
} catch (e) {
}
return result;
}
}
/**
* @param {!SDK.DebuggerModel.CallFrame} callFrame
* @return {!Promise<?Object>}
*/
async function completionsOnPause(callFrame) {
const result = [{items: ['this']}];
const scopeChain = callFrame.scopeChain();
const groupPromises = [];
for (const scope of scopeChain) {
groupPromises.push(scope.object()
.getAllPropertiesPromise(false /* accessorPropertiesOnly */, false /* generatePreview */)
.then(result => ({properties: result.properties, name: scope.name()})));
}
const fullScopes = await Promise.all(groupPromises);
executionContext.runtimeModel.releaseObjectGroup('completion');
for (const scope of fullScopes)
result.push({title: scope.name, items: scope.properties.map(property => property.name).sort()});
return result;
}
}
/**
* @param {?Array<!ObjectUI.JavaScriptAutocomplete.CompletionGroup>} propertyGroups
* @param {boolean} dotNotation
* @param {boolean} bracketNotation
* @param {string} expressionString
* @param {string} query
* @return {!UI.SuggestBox.Suggestions}
*/
_receivedPropertyNames(propertyGroups, dotNotation, bracketNotation, expressionString, query) {
if (!propertyGroups)
return [];
const includeCommandLineAPI = (!dotNotation && !bracketNotation);
if (includeCommandLineAPI) {
const commandLineAPI = [
'dir',
'dirxml',
'keys',
'values',
'profile',
'profileEnd',
'monitorEvents',
'unmonitorEvents',
'inspect',
'copy',
'clear',
'getEventListeners',
'debug',
'undebug',
'monitor',
'unmonitor',
'table',
'queryObjects',
'$',
'$$',
'$x'
];
propertyGroups.push({items: commandLineAPI});
}
return this._completionsForQuery(dotNotation, bracketNotation, expressionString, query, propertyGroups);
}
/**
* @param {boolean} dotNotation
* @param {boolean} bracketNotation
* @param {string} expressionString
* @param {string} query
* @param {!Array<!ObjectUI.JavaScriptAutocomplete.CompletionGroup>} propertyGroups
* @return {!UI.SuggestBox.Suggestions}
*/
_completionsForQuery(dotNotation, bracketNotation, expressionString, query, propertyGroups) {
const quoteUsed = (bracketNotation && query.startsWith('\'')) ? '\'' : '"';
if (!expressionString) {
const keywords = [
'break', 'case', 'catch', 'continue', 'default', 'delete', 'do', 'else', 'finally',
'for', 'function', 'if', 'in', 'instanceof', 'new', 'return', 'switch', 'this',
'throw', 'try', 'typeof', 'var', 'void', 'while', 'with'
];
propertyGroups.push({title: Common.UIString('keywords'), items: keywords});
}
let result = [];
let lastGroupTitle;
const regex = /^[a-zA-Z_$\u008F-\uFFFF][a-zA-Z0-9_$\u008F-\uFFFF]*$/;
const lowerCaseQuery = query.toLowerCase();
for (const group of propertyGroups) {
const caseSensitivePrefix = [];
const caseInsensitivePrefix = [];
const caseSensitiveAnywhere = [];
const caseInsensitiveAnywhere = [];
for (let i = 0; i < group.items.length; i++) {
let property = group.items[i];
// Assume that all non-ASCII characters are letters and thus can be used as part of identifier.
if (!bracketNotation && !regex.test(property))
continue;
if (bracketNotation) {
if (!/^[0-9]+$/.test(property))
property = quoteUsed + property.escapeCharacters(quoteUsed + '\\') + quoteUsed;
property += ']';
}
if (property.length < query.length)
continue;
const lowerCaseProperty = property.toLowerCase();
if (query.length && lowerCaseProperty.indexOf(lowerCaseQuery) === -1)
continue;
if (property.startsWith(query))
caseSensitivePrefix.push({text: property, priority: 4});
else if (lowerCaseProperty.startsWith(lowerCaseQuery))
caseInsensitivePrefix.push({text: property, priority: 3});
else if (property.indexOf(query) !== -1)
caseSensitiveAnywhere.push({text: property, priority: 2});
else
caseInsensitiveAnywhere.push({text: property, priority: 1});
}
const structuredGroup =
caseSensitivePrefix.concat(caseInsensitivePrefix, caseSensitiveAnywhere, caseInsensitiveAnywhere);
if (structuredGroup.length && group.title !== lastGroupTitle) {
structuredGroup[0].subtitle = group.title;
lastGroupTitle = group.title;
}
result = result.concat(structuredGroup);
result.forEach(item => {
if (item.text.endsWith(']'))
item.title = item.text.substring(0, item.text.length - 1);
});
}
return result;
}
/**
* @param {string} a
* @param {string} b
* @return {number}
*/
_itemComparator(a, b) {
const aStartsWithUnderscore = a.startsWith('_');
const bStartsWithUnderscore = b.startsWith('_');
if (aStartsWithUnderscore && !bStartsWithUnderscore)
return 1;
if (bStartsWithUnderscore && !aStartsWithUnderscore)
return -1;
return String.naturalOrderComparator(a, b);
}
};
/** @typedef {{title:(string|undefined), items:Array<string>}} */
ObjectUI.JavaScriptAutocomplete.CompletionGroup;
ObjectUI.javaScriptAutocomplete = new ObjectUI.JavaScriptAutocomplete();