parser.js (7532B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 ["WebConsoleCommandsManager"], 10 "resource://devtools/server/actors/webconsole/commands/manager.js", 11 true 12 ); 13 14 const COMMAND = "command"; 15 const KEY = "key"; 16 const ARG = "arg"; 17 18 const COMMAND_PREFIX = /^:/; 19 const KEY_PREFIX = /^--/; 20 21 // default value for flags 22 const DEFAULT_VALUE = true; 23 const COMMAND_DEFAULT_FLAG = { 24 block: "url", 25 screenshot: "filename", 26 unblock: "url", 27 }; 28 29 /** 30 * When given a string that begins with `:` and a unix style string, 31 * returns the command name and the arguments. 32 * Throws if the command doesn't exist. 33 * This is intended to be used by the WebConsole actor only. 34 * 35 * @param String string 36 * A string to format that begins with `:`. 37 * 38 * @returns Object The command name and the arguments 39 * { command: String, args: Object } 40 */ 41 function getCommandAndArgs(string) { 42 if (!isCommand(string)) { 43 throw Error("getCommandAndArgs was called without `:`"); 44 } 45 string = string.trim(); 46 if (string === ":") { 47 throw Error("Missing a command name after ':'"); 48 } 49 const tokens = string.split(/\s+/).map(createToken); 50 return parseCommand(tokens); 51 } 52 53 /** 54 * creates a token object depending on a string which as a prefix, 55 * either `:` for a command or `--` for a key, or nothing for an argument 56 * 57 * @param String string 58 * A string to use as the basis for the token 59 * 60 * @returns Object Token Object, with the following shape 61 * { type: String, value: String } 62 */ 63 function createToken(string) { 64 if (isCommand(string)) { 65 const value = string.replace(COMMAND_PREFIX, ""); 66 if (!value) { 67 throw Error("Missing a command name after ':'"); 68 } 69 if (!WebConsoleCommandsManager.getAllColonCommandNames().includes(value)) { 70 throw Error(`'${value}' is not a valid command`); 71 } 72 return { type: COMMAND, value }; 73 } 74 if (isKey(string)) { 75 const value = string.replace(KEY_PREFIX, ""); 76 if (!value) { 77 throw Error("invalid flag"); 78 } 79 return { type: KEY, value }; 80 } 81 return { type: ARG, value: string }; 82 } 83 84 /** 85 * returns a command Tree object for a set of tokens 86 * 87 * @param Array Tokens tokens 88 * An array of Token objects 89 * 90 * @returns Object Tree Object, with the following shape 91 * { command: String, args: Object } 92 */ 93 function parseCommand(tokens) { 94 let command = null; 95 const args = {}; 96 97 for (let i = 0; i < tokens.length; i++) { 98 const token = tokens[i]; 99 if (token.type === COMMAND) { 100 if (command) { 101 // we are throwing here because two commands have been passed and it is unclear 102 // what the user's intention was 103 throw Error( 104 "Executing multiple commands in one evaluation is not supported" 105 ); 106 } 107 command = token.value; 108 } 109 110 if (token.type === KEY) { 111 const nextTokenIndex = i + 1; 112 const nextToken = tokens[nextTokenIndex]; 113 let values = args[token.value] || DEFAULT_VALUE; 114 if (nextToken && nextToken.type === ARG) { 115 const { value, offset } = collectString( 116 nextToken, 117 tokens, 118 nextTokenIndex 119 ); 120 // in order for JSON.stringify to correctly output values, they must be correctly 121 // typed 122 // As per the old GCLI documentation, we can only have one value associated with a 123 // flag but multiple flags with the same name can exist and should be combined 124 // into and array. Here we are associating only the value on the right hand 125 // side if it is of type `arg` as a single value; the second case initializes 126 // an array, and the final case pushes a value to an existing array 127 const typedValue = getTypedValue(value); 128 if (values === DEFAULT_VALUE) { 129 values = typedValue; 130 } else if (!Array.isArray(values)) { 131 values = [values, typedValue]; 132 } else { 133 values.push(typedValue); 134 } 135 // skip the next token since we have already consumed it 136 i = nextTokenIndex + offset; 137 } 138 args[token.value] = values; 139 } 140 141 // Since this has only been implemented for screenshot, we can only have one default 142 // value. Eventually we may have more default values. For now, ignore multiple 143 // unflagged args 144 const defaultFlag = COMMAND_DEFAULT_FLAG[command]; 145 if (token.type === ARG && !args[defaultFlag]) { 146 const { value, offset } = collectString(token, tokens, i); 147 // Throw if the command isn't registered in COMMAND_DEFAULT_FLAG 148 // as this command may not expect any argument without an explicit argument name like "-name arg" 149 if (!defaultFlag) { 150 throw new Error( 151 `:${command} command doesn't support unnamed '${value}' argument.` 152 ); 153 } 154 args[defaultFlag] = getTypedValue(value); 155 i = i + offset; 156 } 157 } 158 return { command, args }; 159 } 160 161 const stringChars = ['"', "'", "`"]; 162 function isStringChar(testChar) { 163 return stringChars.includes(testChar); 164 } 165 166 function checkLastChar(string, testChar) { 167 const lastChar = string[string.length - 1]; 168 return lastChar === testChar; 169 } 170 171 function hasUnescapedChar(value, char, rightOffset, leftOffset) { 172 const lastPos = value.length - 1; 173 const string = value.slice(rightOffset, lastPos - leftOffset); 174 const index = string.indexOf(char); 175 if (index === -1) { 176 return false; 177 } 178 const prevChar = index > 0 ? string[index - 1] : null; 179 // return false if the unexpected character is escaped, true if it is not 180 return prevChar !== "\\"; 181 } 182 183 function collectString(token, tokens, index) { 184 const firstChar = token.value[0]; 185 const isString = isStringChar(firstChar); 186 const UNESCAPED_CHAR_ERROR = segment => 187 `String has unescaped \`${firstChar}\` in [${segment}...],` + 188 " may miss a space between arguments"; 189 let value = token.value; 190 191 // the test value is not a string, or it is a string but a complete one 192 // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early 193 if (!isString || checkLastChar(value, firstChar)) { 194 return { value, offset: 0 }; 195 } 196 197 if (hasUnescapedChar(value, firstChar, 1, 0)) { 198 throw Error(UNESCAPED_CHAR_ERROR(value)); 199 } 200 201 let offset = null; 202 for (let i = index + 1; i <= tokens.length; i++) { 203 if (i === tokens.length) { 204 throw Error("String does not terminate"); 205 } 206 207 const nextToken = tokens[i]; 208 if (nextToken.type !== ARG) { 209 throw Error(`String does not terminate before flag "${nextToken.value}"`); 210 } 211 212 value = `${value} ${nextToken.value}`; 213 214 if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) { 215 throw Error(UNESCAPED_CHAR_ERROR(value)); 216 } 217 218 if (checkLastChar(nextToken.value, firstChar)) { 219 offset = i - index; 220 break; 221 } 222 } 223 return { value, offset }; 224 } 225 226 function isCommand(string) { 227 return COMMAND_PREFIX.test(string); 228 } 229 230 function isKey(string) { 231 return KEY_PREFIX.test(string); 232 } 233 234 function getTypedValue(value) { 235 if (!isNaN(value)) { 236 return Number(value); 237 } 238 if (value === "true" || value === "false") { 239 return Boolean(value); 240 } 241 if (isStringChar(value[0])) { 242 return value.slice(1, value.length - 1); 243 } 244 return value; 245 } 246 247 exports.getCommandAndArgs = getCommandAndArgs; 248 exports.isCommand = isCommand;