summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/webconsole/commands/parser.js
blob: b37d489c5f4927f80d46cf8045b97055388619a8 (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
/* 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";

loader.lazyRequireGetter(
  this,
  ["WebConsoleCommandsManager"],
  "resource://devtools/server/actors/webconsole/commands/manager.js",
  true
);

const COMMAND = "command";
const KEY = "key";
const ARG = "arg";

const COMMAND_PREFIX = /^:/;
const KEY_PREFIX = /^--/;

// default value for flags
const DEFAULT_VALUE = true;
const COMMAND_DEFAULT_FLAG = {
  block: "url",
  screenshot: "filename",
  unblock: "url",
};

/**
 * When given a string that begins with `:` and a unix style string,
 * format a JS like object.
 * This is intended to be used by the WebConsole actor only.
 *
 * @param String string
 *        A string to format that begins with `:`.
 *
 * @returns String formatted as `command({ ..args })`
 */
function formatCommand(string) {
  if (!isCommand(string)) {
    throw Error("formatCommand was called without `:`");
  }
  const tokens = string.trim().split(/\s+/).map(createToken);
  const { command, args } = parseCommand(tokens);
  const argsString = formatArgs(args);
  return `${command}(${argsString})`;
}

/**
 * collapses the array of arguments from the parsed command into
 * a single string
 *
 * @param Object tree
 *               A tree object produced by parseCommand
 *
 * @returns String formatted as ` { key: value, ... } ` or an empty string
 */
function formatArgs(args) {
  return Object.keys(args).length ? JSON.stringify(args) : "";
}

/**
 * creates a token object depending on a string which as a prefix,
 * either `:` for a command or `--` for a key, or nothing for an argument
 *
 * @param String string
 *               A string to use as the basis for the token
 *
 * @returns Object Token Object, with the following shape
 *                { type: String, value: String }
 */
function createToken(string) {
  if (isCommand(string)) {
    const value = string.replace(COMMAND_PREFIX, "");
    if (
      !value ||
      !WebConsoleCommandsManager.getAllColonCommandNames().includes(value)
    ) {
      throw Error(`'${value}' is not a valid command`);
    }
    return { type: COMMAND, value };
  }
  if (isKey(string)) {
    const value = string.replace(KEY_PREFIX, "");
    if (!value) {
      throw Error("invalid flag");
    }
    return { type: KEY, value };
  }
  return { type: ARG, value: string };
}

/**
 * returns a command Tree object for a set of tokens
 *
 *
 * @param Array Tokens tokens
 *                     An array of Token objects
 *
 * @returns Object Tree Object, with the following shape
 *                 { command: String, args: Array of Strings }
 */
function parseCommand(tokens) {
  let command = null;
  const args = {};

  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    if (token.type === COMMAND) {
      if (command) {
        // we are throwing here because two commands have been passed and it is unclear
        // what the user's intention was
        throw Error("Invalid command");
      }
      command = token.value;
    }

    if (token.type === KEY) {
      const nextTokenIndex = i + 1;
      const nextToken = tokens[nextTokenIndex];
      let values = args[token.value] || DEFAULT_VALUE;
      if (nextToken && nextToken.type === ARG) {
        const { value, offset } = collectString(
          nextToken,
          tokens,
          nextTokenIndex
        );
        // in order for JSON.stringify to correctly output values, they must be correctly
        // typed
        // As per the old GCLI documentation, we can only have one value associated with a
        // flag but multiple flags with the same name can exist and should be combined
        // into and array.  Here we are associating only the value on the right hand
        // side if it is of type `arg` as a single value; the second case initializes
        // an array, and the final case pushes a value to an existing array
        const typedValue = getTypedValue(value);
        if (values === DEFAULT_VALUE) {
          values = typedValue;
        } else if (!Array.isArray(values)) {
          values = [values, typedValue];
        } else {
          values.push(typedValue);
        }
        // skip the next token since we have already consumed it
        i = nextTokenIndex + offset;
      }
      args[token.value] = values;
    }

    // Since this has only been implemented for screenshot, we can only have one default
    // value. Eventually we may have more default values. For now, ignore multiple
    // unflagged args
    const defaultFlag = COMMAND_DEFAULT_FLAG[command];
    if (token.type === ARG && !args[defaultFlag]) {
      const { value, offset } = collectString(token, tokens, i);
      args[defaultFlag] = getTypedValue(value);
      i = i + offset;
    }
  }
  return { command, args };
}

const stringChars = ['"', "'", "`"];
function isStringChar(testChar) {
  return stringChars.includes(testChar);
}

function checkLastChar(string, testChar) {
  const lastChar = string[string.length - 1];
  return lastChar === testChar;
}

function hasUnescapedChar(value, char, rightOffset, leftOffset) {
  const lastPos = value.length - 1;
  const string = value.slice(rightOffset, lastPos - leftOffset);
  const index = string.indexOf(char);
  if (index === -1) {
    return false;
  }
  const prevChar = index > 0 ? string[index - 1] : null;
  // return false if the unexpected character is escaped, true if it is not
  return prevChar !== "\\";
}

function collectString(token, tokens, index) {
  const firstChar = token.value[0];
  const isString = isStringChar(firstChar);
  const UNESCAPED_CHAR_ERROR = segment =>
    `String has unescaped \`${firstChar}\` in [${segment}...],` +
    " may miss a space between arguments";
  let value = token.value;

  // the test value is not a string, or it is a string but a complete one
  // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early
  if (!isString || checkLastChar(value, firstChar)) {
    return { value, offset: 0 };
  }

  if (hasUnescapedChar(value, firstChar, 1, 0)) {
    throw Error(UNESCAPED_CHAR_ERROR(value));
  }

  let offset = null;
  for (let i = index + 1; i <= tokens.length; i++) {
    if (i === tokens.length) {
      throw Error("String does not terminate");
    }

    const nextToken = tokens[i];
    if (nextToken.type !== ARG) {
      throw Error(`String does not terminate before flag "${nextToken.value}"`);
    }

    value = `${value} ${nextToken.value}`;

    if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) {
      throw Error(UNESCAPED_CHAR_ERROR(value));
    }

    if (checkLastChar(nextToken.value, firstChar)) {
      offset = i - index;
      break;
    }
  }
  return { value, offset };
}

function isCommand(string) {
  return COMMAND_PREFIX.test(string);
}

function isKey(string) {
  return KEY_PREFIX.test(string);
}

function getTypedValue(value) {
  if (!isNaN(value)) {
    return Number(value);
  }
  if (value === "true" || value === "false") {
    return Boolean(value);
  }
  if (isStringChar(value[0])) {
    return value.slice(1, value.length - 1);
  }
  return value;
}

exports.formatCommand = formatCommand;
exports.isCommand = isCommand;