diff --git a/BUILD.gn b/BUILD.gn index 92bf988e20..efc2371572 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -703,6 +703,7 @@ all_devtools_files = [ "front_end/terminal/xterm.js/build/xterm.js", "front_end/test_runner/module.json", "front_end/test_runner/TestRunner.js", + "front_end/text_editor/autocompleteTooltip.css", "front_end/text_editor/cmdevtools.css", "front_end/text_editor/CodeMirrorTextEditor.js", "front_end/text_editor/CodeMirrorUtils.js", diff --git a/front_end/console/ConsolePrompt.js b/front_end/console/ConsolePrompt.js index 509c25eb15..41902804e7 100644 --- a/front_end/console/ConsolePrompt.js +++ b/front_end/console/ConsolePrompt.js @@ -1,9 +1,7 @@ // 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. -/** - * @unrestricted - */ + Console.ConsolePrompt = class extends UI.Widget { constructor() { super(); @@ -11,6 +9,7 @@ Console.ConsolePrompt = class extends UI.Widget { this._history = new Console.ConsoleHistoryManager(); this._initialText = ''; + /** @type {?UI.TextEditor} */ this._editor = null; this._isBelowPromptEnabled = Runtime.experiments.isEnabled('consoleBelowPrompt'); this._eagerPreviewElement = createElementWithClass('div', 'console-eager-preview'); @@ -25,6 +24,8 @@ Console.ConsolePrompt = class extends UI.Widget { this._eagerPreviewElement.classList.toggle('hidden', !this._eagerEvalSetting.get()); this.element.tabIndex = 0; + /** @type {?Promise} */ + this._previewRequestForTest = null; self.runtime.extension(UI.TextEditorFactory).instance().then(gotFactory.bind(this)); @@ -39,6 +40,7 @@ Console.ConsolePrompt = class extends UI.Widget { this._editor.configureAutocomplete({ substituteRangeCallback: this._substituteRange.bind(this), suggestionsCallback: this._wordsWithQuery.bind(this), + tooltipCallback: (lineNumber, columnNumber) => this._tooltipCallback(lineNumber, columnNumber) }); this._editor.widget().element.addEventListener('keydown', this._editorKeyDown.bind(this), true); this._editor.widget().show(this.element); @@ -371,6 +373,35 @@ Console.ConsolePrompt = class extends UI.Widget { .then(words => words.concat(historyWords)); } + /** + * @param {number} lineNumber + * @param {number} columnNumber + * @return {!Promise} + */ + async _tooltipCallback(lineNumber, columnNumber) { + const before = this._editor.text(new TextUtils.TextRange(0, 0, lineNumber, columnNumber)); + const result = await ObjectUI.javaScriptAutocomplete.argumentsHint(before); + if (!result) + return null; + const argumentsElement = createElement('span'); + for (let i = 0; i < result.args.length; i++) { + if (i === result.argumentIndex || (i < result.argumentIndex && result.args[i].startsWith('...'))) { + const boldElement = createElement('b'); + boldElement.textContent = result.args[i]; + argumentsElement.appendChild(boldElement); + } else { + argumentsElement.createTextChild(result.args[i]); + } + if (i < result.args.length - 1) + argumentsElement.createTextChild(', '); + } + const tooltip = createElementWithClass('span', 'source-code'); + tooltip.createTextChild('\u0192('); + tooltip.appendChild(argumentsElement); + tooltip.createTextChild(')'); + return tooltip; + } + _editorSetForTest() { } }; diff --git a/front_end/formatter/FormatterWorkerPool.js b/front_end/formatter/FormatterWorkerPool.js index 6890f5073d..95fe35d428 100644 --- a/front_end/formatter/FormatterWorkerPool.js +++ b/front_end/formatter/FormatterWorkerPool.js @@ -239,6 +239,23 @@ Formatter.FormatterWorkerPool = class { return /** @type {!Promise} */ ( this._runTask('findLastExpression', {content})); } + + /** + * @param {string} content + * @return {!Promise} + */ + findLastFunctionCall(content) { + return /** @type {!Promise} */ ( + this._runTask('findLastFunctionCall', {content})); + } + + /** + * @param {string} content + * @return {!Promise>} + */ + argumentsList(content) { + return /** @type {!Promise>} */ (this._runTask('argumentsList', {content})); + } }; Formatter.FormatterWorkerPool.MaxWorkers = 2; diff --git a/front_end/formatter_worker/FormatterWorker.js b/front_end/formatter_worker/FormatterWorker.js index 791c5a8162..d0d00c6adc 100644 --- a/front_end/formatter_worker/FormatterWorker.js +++ b/front_end/formatter_worker/FormatterWorker.js @@ -87,6 +87,12 @@ self.onmessage = function(event) { case 'findLastExpression': postMessage(FormatterWorker.findLastExpression(params.content)); break; + case 'findLastFunctionCall': + postMessage(FormatterWorker.findLastFunctionCall(params.content)); + break; + case 'argumentsList': + postMessage(FormatterWorker.argumentsList(params.content)); + break; default: console.error('Unsupport method name: ' + method); } @@ -320,6 +326,95 @@ FormatterWorker.format = function(mimeType, text, indentString) { postMessage(result); }; +/** + * @param {string} content + * @return {?{baseExpression: string, possibleSideEffects:boolean, argumentIndex: number}} + */ +FormatterWorker.findLastFunctionCall = function(content) { + if (content.length > 10000) + return null; + try { + const tokenizer = acorn.tokenizer(content, {ecmaVersion: 9}); + while (tokenizer.getToken().type !== acorn.tokTypes.eof) { + } + } catch (e) { + return null; + } + + const suffix = '000)'; + const base = FormatterWorker._lastCompleteExpression(content, suffix, new Set(['CallExpression', 'NewExpression'])); + if (!base) + return null; + const callee = base.baseNode['callee']; + const argumentIndex = base.baseNode['arguments'].length - 1; + const baseExpression = base.baseExpression.substring(callee.start, callee.end); + const possibleSideEffects = FormatterWorker._nodeHasPossibleSideEffects(callee); + return {baseExpression, possibleSideEffects, argumentIndex}; +}; + +/** + * @param {string} content + * @return {!Array} + */ +FormatterWorker.argumentsList = function(content) { + if (content.length > 10000) + return []; + let parsed = null; + try { + // Try to parse as a function, anonymous function, or arrow function. + parsed = acorn.parse(`(${content})`, {ecmaVersion: 9}); + } catch (e) { + } + if (!parsed) { + try { + // Try to parse as a method. + parsed = acorn.parse(`({${content}})`, {ecmaVersion: 9}); + } catch (e) { + } + } + if (!parsed || !parsed.body || !parsed.body[0] || !parsed.body[0].expression) + return []; + const expression = parsed.body[0].expression; + let params = null; + switch (expression.type) { + case 'ClassExpression': + if (!expression.body.body) + break; + const constructor = expression.body.body.find(method => method.kind === 'constructor'); + if (constructor) + params = constructor.value.params; + break; + case 'ObjectExpression': + if (!expression.properties[0] || !expression.properties[0].value) + break; + params = expression.properties[0].value.params; + break; + case 'FunctionExpression': + case 'ArrowFunctionExpression': + params = expression.params; + break; + } + if (!params) + return []; + return params.map(paramName); + + function paramName(param) { + switch (param.type) { + case 'Identifier': + return param.name; + case 'AssignmentPattern': + return '?' + paramName(param.left); + case 'ObjectPattern': + return 'obj'; + case 'ArrayPattern': + return 'arr'; + case 'RestElement': + return '...' + paramName(param.argument); + } + return '?'; + } +}; + /** * @param {string} content * @return {?{baseExpression: string, possibleSideEffects:boolean}} @@ -335,8 +430,6 @@ FormatterWorker.findLastExpression = function(content) { return null; } - /** @type {!ESTree.Node} */ - let ast; const suffix = '.DEVTOOLS'; try { acorn.parse(content + suffix, {ecmaVersion: 9}); @@ -345,6 +438,23 @@ FormatterWorker.findLastExpression = function(content) { if (parseError.message.startsWith('Unexpected token') && parseError.pos === content.length) return null; } + const base = FormatterWorker._lastCompleteExpression(content, suffix, new Set(['MemberExpression', 'Identifier'])); + if (!base) + return null; + const {baseExpression, baseNode} = base; + const possibleSideEffects = FormatterWorker._nodeHasPossibleSideEffects(baseNode); + return {baseExpression, possibleSideEffects}; +}; + +/** + * @param {string} content + * @param {string} suffix + * @param {!Set} types + * @return {?{baseNode: !ESTree.Node, baseExpression: string}} + */ +FormatterWorker._lastCompleteExpression = function(content, suffix, types) { + /** @type {!ESTree.Node} */ + let ast; let parsedContent = ''; for (let i = 0; i < content.length; i++) { try { @@ -357,7 +467,6 @@ FormatterWorker.findLastExpression = function(content) { } if (!ast) return null; - const types = new Set(['MemberExpression', 'Identifier']); let baseNode = null; const walker = new FormatterWorker.ESTreeWalker(node => { if (baseNode || node.end < ast.end) @@ -371,6 +480,14 @@ FormatterWorker.findLastExpression = function(content) { let baseExpression = parsedContent.substring(baseNode.start, parsedContent.length - suffix.length); if (baseExpression.startsWith('{')) baseExpression = `(${baseExpression})`; + return {baseNode, baseExpression}; +}; + +/** + * @param {!ESTree.Node} baseNode + * @return {boolean} + */ +FormatterWorker._nodeHasPossibleSideEffects = function(baseNode) { const sideEffectFreeTypes = new Set([ 'MemberExpression', 'Identifier', 'BinaryExpression', 'Literal', 'TemplateLiteral', 'TemplateElement', 'ObjectExpression', 'ArrayExpression', 'Property', 'ThisExpression' @@ -383,7 +500,7 @@ FormatterWorker.findLastExpression = function(content) { return FormatterWorker.ESTreeWalker.SkipSubtree; }); sideEffectwalker.walk(/** @type {!ESTree.Node} */ (baseNode)); - return {baseExpression, possibleSideEffects}; + return possibleSideEffects; }; /** diff --git a/front_end/object_ui/JavaScriptAutocomplete.js b/front_end/object_ui/JavaScriptAutocomplete.js index 037dc20106..6b8dfd9979 100644 --- a/front_end/object_ui/JavaScriptAutocomplete.js +++ b/front_end/object_ui/JavaScriptAutocomplete.js @@ -32,6 +32,43 @@ ObjectUI.JavaScriptAutocomplete = class { return mapCompletions.concat(expressionCompletions); } + /** + * @param {string} fullText + * @return {!Promise, argumentIndex: number}>} + */ + async argumentsHint(fullText) { + const functionCall = await Formatter.formatterWorkerPool().findLastFunctionCall(fullText); + if (!functionCall) + return null; + const executionContext = UI.context.flavor(SDK.ExecutionContext); + if (!executionContext) + return null; + const result = await executionContext.evaluate( + { + expression: functionCall.baseExpression, + objectGroup: 'argumentsHint', + includeCommandLineAPI: true, + silent: true, + returnByValue: false, + generatePreview: false, + throwOnSideEffect: functionCall.possibleSideEffects, + timeout: functionCall.possibleSideEffects ? 500 : undefined + }, + /* userGesture */ false, /* awaitPromise */ false); + if (!result || result.exceptionDetails || !result.object || result.object.type !== 'function') + return null; + executionContext.runtimeModel.releaseObjectGroup('argumentsHint'); + + const description = result.object.description; + if (description.endsWith('{ [native code] }')) + return null; // TODO(einbinder) support native function argument hints + const args = await Formatter.formatterWorkerPool().argumentsList(description); + + if (!args.length) + return null; + return {args, argumentIndex: functionCall.argumentIndex}; + } + /** * @param {string} text * @param {string} query diff --git a/front_end/text_editor/TextEditorAutocompleteController.js b/front_end/text_editor/TextEditorAutocompleteController.js index 93d4e26613..7a210d9391 100644 --- a/front_end/text_editor/TextEditorAutocompleteController.js +++ b/front_end/text_editor/TextEditorAutocompleteController.js @@ -22,7 +22,10 @@ TextEditor.TextEditorAutocompleteController = class { this._changes = this._changes.bind(this); this._blur = this._blur.bind(this); this._beforeChange = this._beforeChange.bind(this); - this._mouseDown = this.clearAutocomplete.bind(this); + this._mouseDown = () => { + this.clearAutocomplete(); + this._tooltipGlassPane.hide(); + }; this._codeMirror.on('changes', this._changes); this._lastHintText = ''; /** @type {?UI.SuggestBox} */ @@ -30,6 +33,14 @@ TextEditor.TextEditorAutocompleteController = class { /** @type {?string} */ this._currentSuggestion = null; this._hintElement = createElementWithClass('span', 'auto-complete-text'); + + this._tooltipGlassPane = new UI.GlassPane(); + this._tooltipGlassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MeasureContent); + this._tooltipGlassPane.setOutsideClickCallback(this._tooltipGlassPane.hide.bind(this._tooltipGlassPane)); + this._tooltipElement = createElementWithClass('div', 'autocomplete-tooltip'); + const shadowRoot = + UI.createShadowRootWithCoreStyles(this._tooltipGlassPane.contentElement, 'text_editor/autocompleteTooltip.css'); + shadowRoot.appendChild(this._tooltipElement); } _initializeIfNeeded() { @@ -220,14 +231,14 @@ TextEditor.TextEditorAutocompleteController = class { autocomplete(force) { this._initializeIfNeeded(); if (this._codeMirror.somethingSelected()) { - this.clearAutocomplete(); + this._hideSuggestBox(); return; } const cursor = this._codeMirror.getCursor('head'); const substituteRange = this._substituteRange(cursor.line, cursor.ch); if (!substituteRange || !this._validateSelectionsContexts(substituteRange)) { - this.clearAutocomplete(); + this._hideSuggestBox(); return; } @@ -246,7 +257,7 @@ TextEditor.TextEditorAutocompleteController = class { function wordsAcquired(wordsWithQuery) { if (!wordsWithQuery.length || (wordsWithQuery.length === 1 && query === wordsWithQuery[0].text) || (!this._suggestBox && hadSuggestBox)) { - this.clearAutocomplete(); + this._hideSuggestBox(); this._onSuggestionsShownForTest([]); return; } @@ -259,6 +270,8 @@ TextEditor.TextEditorAutocompleteController = class { queryRange.startColumn !== oldQueryRange.startColumn) this._updateAnchorBox(); this._suggestBox.updateSuggestions(this._anchorBox, wordsWithQuery, true, !this._isCursorAtEndOfLine(), query); + if (this._suggestBox.visible) + this._tooltipGlassPane.hide(); this._onSuggestionsShownForTest(wordsWithQuery); } } @@ -311,6 +324,11 @@ TextEditor.TextEditorAutocompleteController = class { } clearAutocomplete() { + this._tooltipGlassPane.hide(); + this._hideSuggestBox(); + } + + _hideSuggestBox() { if (!this._suggestBox) return; this._suggestBox.hide(); @@ -328,6 +346,10 @@ TextEditor.TextEditorAutocompleteController = class { * @return {boolean} */ keyDown(event) { + if (this._tooltipGlassPane.isShowing() && event.keyCode === UI.KeyboardShortcut.Keys.Esc.code) { + this._tooltipGlassPane.hide(); + return true; + } if (!this._suggestBox) return false; switch (event.keyCode) { @@ -417,6 +439,7 @@ TextEditor.TextEditorAutocompleteController = class { } _onScroll() { + this._tooltipGlassPane.hide(); if (!this._suggestBox) return; const cursor = this._codeMirror.getCursor(); @@ -431,7 +454,34 @@ TextEditor.TextEditorAutocompleteController = class { } } + async _updateTooltip() { + const cursor = this._codeMirror.getCursor(); + const tooltip = this._config.tooltipCallback ? await this._config.tooltipCallback(cursor.line, cursor.ch) : null; + const newCursor = this._codeMirror.getCursor(); + + if (newCursor.line !== cursor.line && newCursor.ch !== cursor.ch) + return; + if (this._suggestBox && this._suggestBox.visible) + return; + + if (!tooltip) { + this._tooltipGlassPane.hide(); + return; + } + const metrics = this._textEditor.cursorPositionToCoordinates(cursor.line, cursor.ch); + if (!metrics) { + this._tooltipGlassPane.hide(); + return; + } + + this._tooltipGlassPane.setContentAnchorBox(new AnchorBox(metrics.x, metrics.y, 0, metrics.height)); + this._tooltipElement.removeChildren(); + this._tooltipElement.appendChild(tooltip); + this._tooltipGlassPane.show(/** @type {!Document} */ (this._textEditor.element.ownerDocument)); + } + _onCursorActivity() { + this._updateTooltip(); if (!this._suggestBox) return; const cursor = this._codeMirror.getCursor(); diff --git a/front_end/text_editor/autocompleteTooltip.css b/front_end/text_editor/autocompleteTooltip.css new file mode 100644 index 0000000000..f125260837 --- /dev/null +++ b/front_end/text_editor/autocompleteTooltip.css @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2018 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. + */ +.autocomplete-tooltip { + background-color: #FFFFFF; + pointer-events: none; + margin-left: -3px; + box-shadow: var(--drop-shadow); + overflow-x: hidden; + height: 20px; +} + +.autocomplete-tooltip > * { + padding: 0px 4px; + white-space: nowrap; + vertical-align: middle; + line-height: 20px; +} \ No newline at end of file diff --git a/front_end/text_editor/module.json b/front_end/text_editor/module.json index 429098b6fd..e1a6b600fc 100644 --- a/front_end/text_editor/module.json +++ b/front_end/text_editor/module.json @@ -26,6 +26,7 @@ "CodeMirrorTextEditor.js" ], "resources": [ + "autocompleteTooltip.css", "cmdevtools.css" ], "skip_compilation": [ diff --git a/front_end/ui/TextEditor.js b/front_end/ui/TextEditor.js index 8bf364db70..d8aeb048bc 100644 --- a/front_end/ui/TextEditor.js +++ b/front_end/ui/TextEditor.js @@ -116,6 +116,7 @@ UI.TextEditor.Options; /** * @typedef {{ * substituteRangeCallback: ((function(number, number):?TextUtils.TextRange)|undefined), + * tooltipCallback: ((function(number, number):?Element)|undefined), * suggestionsCallback: ((function(!TextUtils.TextRange, !TextUtils.TextRange, boolean=):?Promise.)|undefined), * isWordChar: ((function(string):boolean)|undefined) * }}