summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/actions/autocomplete.js
blob: e2d08351ecf3683a966773c797bfe93d0043ccf8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
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,
};