diff options
Diffstat (limited to 'devtools/client/webconsole/actions/autocomplete.js')
-rw-r--r-- | devtools/client/webconsole/actions/autocomplete.js | 376 |
1 files changed, 376 insertions, 0 deletions
diff --git a/devtools/client/webconsole/actions/autocomplete.js b/devtools/client/webconsole/actions/autocomplete.js new file mode 100644 index 0000000000..e2d08351ec --- /dev/null +++ b/devtools/client/webconsole/actions/autocomplete.js @@ -0,0 +1,376 @@ +/* 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 { + AUTOCOMPLETE_CLEAR, + AUTOCOMPLETE_DATA_RECEIVE, + AUTOCOMPLETE_PENDING_REQUEST, + AUTOCOMPLETE_RETRIEVE_FROM_CACHE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const { + analyzeInputString, + shouldInputBeAutocompleted, +} = require("resource://devtools/shared/webconsole/analyze-input-string.js"); + +loader.lazyRequireGetter( + this, + "getSelectedTarget", + "resource://devtools/shared/commands/target/selectors/targets.js", + true +); + +/** + * Update the data used for the autocomplete popup in the console input (JsTerm). + * + * @param {Boolean} force: True to force a call to the server (as opposed to retrieve + * from the cache). + * @param {Array<String>} getterPath: Array representing the getter access (i.e. + * `a.b.c.d.` is described as ['a', 'b', 'c', 'd'] ). + * @param {Array<String>} expressionVars: Array of the variables defined in the expression. + */ +function autocompleteUpdate(force, getterPath, expressionVars) { + return async ({ dispatch, getState, webConsoleUI, hud }) => { + if (hud.inputHasSelection()) { + return dispatch(autocompleteClear()); + } + + const inputValue = hud.getInputValue(); + const mappedVars = hud.getMappedVariables() ?? {}; + const allVars = (expressionVars ?? []).concat(Object.keys(mappedVars)); + const frameActorId = await hud.getSelectedFrameActorID(); + + const cursor = webConsoleUI.getInputCursor(); + + const state = getState().autocomplete; + const { cache } = state; + if ( + !force && + (!inputValue || /^[a-zA-Z0-9_$]/.test(inputValue.substring(cursor))) + ) { + return dispatch(autocompleteClear()); + } + + const rawInput = inputValue.substring(0, cursor); + const retrieveFromCache = + !force && + cache && + cache.input && + rawInput.startsWith(cache.input) && + /[a-zA-Z0-9]$/.test(rawInput) && + frameActorId === cache.frameActorId; + + if (retrieveFromCache) { + return dispatch(autoCompleteDataRetrieveFromCache(rawInput)); + } + + const authorizedEvaluations = updateAuthorizedEvaluations( + state.authorizedEvaluations, + getterPath, + mappedVars + ); + + const { input, originalExpression } = await getMappedInput( + rawInput, + mappedVars, + hud + ); + + return dispatch( + autocompleteDataFetch({ + input, + frameActorId, + authorizedEvaluations, + force, + allVars, + mappedVars, + originalExpression, + }) + ); + }; +} + +/** + * Combine or replace authorizedEvaluations with the newly authorized getter path, if any. + * @param {Array<Array<String>>} authorizedEvaluations Existing authorized evaluations (may + * be updated in place) + * @param {Array<String>} getterPath The new getter path + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @returns {Array<Array<String>>} The updated authorized evaluations (the original array, + * if it was updated in place) */ +function updateAuthorizedEvaluations( + authorizedEvaluations, + getterPath, + mappedVars +) { + if (!Array.isArray(authorizedEvaluations) || !authorizedEvaluations.length) { + authorizedEvaluations = []; + } + + if (Array.isArray(getterPath) && getterPath.length) { + // We need to check for any previous authorizations. For example, here if getterPath + // is ["a", "b", "c", "d"], we want to see if there was any other path that was + // authorized in a previous request. For that, we only add the previous + // authorizations if the last auth is contained in getterPath. (for the example, we + // would keep if it is [["a", "b"]], not if [["b"]] nor [["f", "g"]]) + const last = authorizedEvaluations[authorizedEvaluations.length - 1]; + + const generatedPath = mappedVars[getterPath[0]]?.split("."); + if (generatedPath) { + getterPath = generatedPath.concat(getterPath.slice(1)); + } + + const isMappedVariable = + generatedPath && getterPath.length === generatedPath.length; + const concat = !last || last.every((x, index) => x === getterPath[index]); + if (isMappedVariable) { + // If the path consists only of an original variable, add all the prefixes of its + // mapping. For example, for myVar => a.b.c, authorize a, a.b, and a.b.c. This + // ensures we'll only show a prompt for myVar once even if a.b and a.b.c are both + // unsafe getters. + authorizedEvaluations = generatedPath.map((_, i) => + generatedPath.slice(0, i + 1) + ); + } else if (concat) { + authorizedEvaluations.push(getterPath); + } else { + authorizedEvaluations = [getterPath]; + } + } + return authorizedEvaluations; +} + +/** + * Apply source mapping to the autocomplete input. + * @param {String} rawInput The input to map. + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @param {WebConsole} hud A reference to the webconsole hud. + * @returns {String} The source-mapped expression to autocomplete. + */ +async function getMappedInput(rawInput, mappedVars, hud) { + if (!mappedVars || !Object.keys(mappedVars).length) { + return { input: rawInput, originalExpression: undefined }; + } + + const inputAnalysis = analyzeInputString(rawInput, 500); + if (!shouldInputBeAutocompleted(inputAnalysis)) { + return { input: rawInput, originalExpression: undefined }; + } + + const { + mainExpression: originalExpression, + isPropertyAccess, + isElementAccess, + lastStatement, + } = inputAnalysis; + + // If we're autocompleting a variable name, pass it through unchanged so that we + // show original variable names rather than generated ones. + // For example, if we have the mapping `myVariable` => `x`, show variables starting + // with myVariable rather than x. + if (!isPropertyAccess && !isElementAccess) { + return { input: lastStatement, originalExpression }; + } + + let generated = + (await hud.getMappedExpression(originalExpression))?.expression ?? + originalExpression; + // Strip off the semicolon if the expression was converted to a statement + const trailingSemicolon = /;\s*$/; + if ( + trailingSemicolon.test(generated) && + !trailingSemicolon.test(originalExpression) + ) { + generated = generated.slice(0, generated.lastIndexOf(";")); + } + + const suffix = lastStatement.slice(originalExpression.length); + return { input: generated + suffix, originalExpression }; +} + +/** + * Called when the autocompletion data should be cleared. + */ +function autocompleteClear() { + return { + type: AUTOCOMPLETE_CLEAR, + }; +} + +/** + * Called when the autocompletion data should be retrieved from the cache (i.e. + * client-side). + * + * @param {String} input: The input used to filter the cached data. + */ +function autoCompleteDataRetrieveFromCache(input) { + return { + type: AUTOCOMPLETE_RETRIEVE_FROM_CACHE, + input, + }; +} + +let currentRequestId = 0; +function generateRequestId() { + return currentRequestId++; +} + +/** + * Action that fetch autocompletion data from the server. + * + * @param {Object} Object of the following shape: + * - {String} input: the expression that we want to complete. + * - {String} frameActorId: The id of the frame we want to autocomplete in. + * - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space). + * - {Array} authorizedEvaluations: Array of the properties access which can be + * executed by the engine. + * Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]] + * to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`. + */ +function autocompleteDataFetch({ + input, + frameActorId, + force, + authorizedEvaluations, + allVars, + mappedVars, + originalExpression, +}) { + return async ({ dispatch, commands, webConsoleUI, hud }) => { + // Retrieve the right WebConsole front that relates either to (by order of priority): + // - the currently selected target in the context selector + // (contextSelectedTargetFront), + // - the currently selected Node in the inspector (selectedNodeActor), + // - the currently selected frame in the debugger (when paused) (frameActor), + // - the currently selected target in the iframe dropdown + // (selectedTargetFront from the TargetCommand) + const selectedNodeActorId = webConsoleUI.getSelectedNodeActorID(); + + let targetFront = commands.targetCommand.selectedTargetFront; + // Note that getSelectedTargetFront will return null if we default to the top level target. + const contextSelectorTargetFront = getSelectedTarget( + hud.commands.targetCommand.store.getState() + ); + const selectedActorId = selectedNodeActorId || frameActorId; + if (contextSelectorTargetFront) { + targetFront = contextSelectorTargetFront; + } else if (selectedActorId) { + const selectedFront = commands.client.getFrontByID(selectedActorId); + if (selectedFront) { + targetFront = selectedFront.targetFront; + } + } + + const webconsoleFront = await targetFront.getFront("console"); + + const id = generateRequestId(); + dispatch({ type: AUTOCOMPLETE_PENDING_REQUEST, id }); + + webconsoleFront + .autocomplete( + input, + undefined, + frameActorId, + selectedNodeActorId, + authorizedEvaluations, + allVars + ) + .then(data => { + if (data.isUnsafeGetter && originalExpression !== undefined) { + data.getterPath = unmapGetterPath( + data.getterPath, + originalExpression, + mappedVars + ); + } + return dispatch( + autocompleteDataReceive({ + id, + input, + force, + frameActorId, + data, + authorizedEvaluations, + }) + ); + }) + .catch(e => { + console.error("failed autocomplete", e); + dispatch(autocompleteClear()); + }); + }; +} + +/** + * Replace generated variable names in an unsafe getter path with their original + * counterparts. + * @param {Array<String>} getterPath Array of properties leading up to and including the + * unsafe getter. + * @param {String} originalExpression The expression that was evaluated, before mapping. + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @returns {Array<String>} An updated getter path containing original variables. + */ +function unmapGetterPath(getterPath, originalExpression, mappedVars) { + // We know that the original expression is a sequence of property accesses, that only + // the first part can be a mapped variable, and that the getter path must start with + // its generated path or be a prefix of it. + + // Suppose we have the expression `foo.bar`, which maps to `a.b.c.bar`. + // Get the first part of the expression ("foo") + const originalVariable = /^[^.[?]*/s.exec(originalExpression)[0].trim(); + const generatedVariable = mappedVars[originalVariable]; + if (generatedVariable) { + // Get number of properties in "a.b.c" + const generatedVariableParts = generatedVariable.split("."); + // Replace ["a", "b", "c"] with "foo" in the getter path. + // Note that this will also work if the getter path ends inside of the mapped + // variable, like ["a", "b"]. + return [ + originalVariable, + ...getterPath.slice(generatedVariableParts.length), + ]; + } + return getterPath; +} + +/** + * Called when we receive the autocompletion data from the server. + * + * @param {Object} Object of the following shape: + * - {Integer} id: The autocompletion request id. This will be used in the reducer + * to check that we update the state with the last request results. + * - {String} input: the expression that we want to complete. + * - {String} frameActorId: The id of the frame we want to autocomplete in. + * - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space). + * - {Object} data: The actual data returned from the server. + * - {Array} authorizedEvaluations: Array of the properties access which can be + * executed by the engine. + * Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]] + * to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`. + */ +function autocompleteDataReceive({ + id, + input, + frameActorId, + force, + data, + authorizedEvaluations, +}) { + return { + type: AUTOCOMPLETE_DATA_RECEIVE, + id, + input, + force, + frameActorId, + data, + authorizedEvaluations, + }; +} + +module.exports = { + autocompleteClear, + autocompleteUpdate, +}; |