diff options
Diffstat (limited to 'devtools/shared/gcli/source/lib/gcli/commands/commands.js')
-rw-r--r-- | devtools/shared/gcli/source/lib/gcli/commands/commands.js | 570 |
1 files changed, 570 insertions, 0 deletions
diff --git a/devtools/shared/gcli/source/lib/gcli/commands/commands.js b/devtools/shared/gcli/source/lib/gcli/commands/commands.js new file mode 100644 index 000000000..67793b2dc --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/commands.js @@ -0,0 +1,570 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var util = require('../util/util'); +var l10n = require('../util/l10n'); + +/** + * Implement the localization algorithm for any documentation objects (i.e. + * description and manual) in a command. + * @param data The data assigned to a description or manual property + * @param onUndefined If data == null, should we return the data untouched or + * lookup a 'we don't know' key in it's place. + */ +function lookup(data, onUndefined) { + if (data == null) { + if (onUndefined) { + return l10n.lookup(onUndefined); + } + + return data; + } + + if (typeof data === 'string') { + return data; + } + + if (typeof data === 'object') { + if (data.key) { + return l10n.lookup(data.key); + } + + var locales = l10n.getPreferredLocales(); + var translated; + locales.some(function(locale) { + translated = data[locale]; + return translated != null; + }); + if (translated != null) { + return translated; + } + + console.error('Can\'t find locale in descriptions: ' + + 'locales=' + JSON.stringify(locales) + ', ' + + 'description=' + JSON.stringify(data)); + return '(No description)'; + } + + return l10n.lookup(onUndefined); +} + + +/** + * The command object is mostly just setup around a commandSpec (as passed to + * Commands.add()). + */ +function Command(types, commandSpec) { + Object.keys(commandSpec).forEach(function(key) { + this[key] = commandSpec[key]; + }, this); + + if (!this.name) { + throw new Error('All registered commands must have a name'); + } + + if (this.params == null) { + this.params = []; + } + if (!Array.isArray(this.params)) { + throw new Error('command.params must be an array in ' + this.name); + } + + this.hasNamedParameters = false; + this.description = 'description' in this ? this.description : undefined; + this.description = lookup(this.description, 'canonDescNone'); + this.manual = 'manual' in this ? this.manual : undefined; + this.manual = lookup(this.manual); + + // At this point this.params has nested param groups. We want to flatten it + // out and replace the param object literals with Parameter objects + var paramSpecs = this.params; + this.params = []; + this.paramGroups = {}; + this._shortParams = {}; + + var addParam = function(param) { + var groupName = param.groupName || l10n.lookup('canonDefaultGroupName'); + this.params.push(param); + if (!this.paramGroups.hasOwnProperty(groupName)) { + this.paramGroups[groupName] = []; + } + this.paramGroups[groupName].push(param); + }.bind(this); + + // Track if the user is trying to mix default params and param groups. + // All the non-grouped parameters must come before all the param groups + // because non-grouped parameters can be assigned positionally, so their + // index is important. We don't want 'holes' in the order caused by + // parameter groups. + var usingGroups = false; + + // In theory this could easily be made recursive, so param groups could + // contain nested param groups. Current thinking is that the added + // complexity for the UI probably isn't worth it, so this implementation + // prevents nesting. + paramSpecs.forEach(function(spec) { + if (!spec.group) { + var param = new Parameter(types, spec, this, null); + addParam(param); + + if (!param.isPositionalAllowed) { + this.hasNamedParameters = true; + } + + if (usingGroups && param.groupName == null) { + throw new Error('Parameters can\'t come after param groups.' + + ' Ignoring ' + this.name + '/' + spec.name); + } + + if (param.groupName != null) { + usingGroups = true; + } + } + else { + spec.params.forEach(function(ispec) { + var param = new Parameter(types, ispec, this, spec.group); + addParam(param); + + if (!param.isPositionalAllowed) { + this.hasNamedParameters = true; + } + }, this); + + usingGroups = true; + } + }, this); + + this.params.forEach(function(param) { + if (param.short != null) { + if (this._shortParams[param.short] != null) { + throw new Error('Multiple params using short name ' + param.short); + } + this._shortParams[param.short] = param; + } + }, this); +} + +/** + * JSON serializer that avoids non-serializable data + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ +Command.prototype.toJson = function(customProps) { + var json = { + item: 'command', + name: this.name, + params: this.params.map(function(param) { return param.toJson(); }), + returnType: this.returnType, + isParent: (this.exec == null) + }; + + if (this.description !== l10n.lookup('canonDescNone')) { + json.description = this.description; + } + if (this.manual != null) { + json.manual = this.manual; + } + if (this.hidden != null) { + json.hidden = this.hidden; + } + + if (Array.isArray(customProps)) { + customProps.forEach(function(prop) { + if (this[prop] != null) { + json[prop] = this[prop]; + } + }.bind(this)); + } + + return json; +}; + +/** + * Easy way to lookup parameters by full name + */ +Command.prototype.getParameterByName = function(name) { + var reply; + this.params.forEach(function(param) { + if (param.name === name) { + reply = param; + } + }); + return reply; +}; + +/** + * Easy way to lookup parameters by short name + */ +Command.prototype.getParameterByShortName = function(short) { + return this._shortParams[short]; +}; + +exports.Command = Command; + + +/** + * A wrapper for a paramSpec so we can sort out shortened versions names for + * option switches + */ +function Parameter(types, paramSpec, command, groupName) { + this.command = command || { name: 'unnamed' }; + this.paramSpec = paramSpec; + this.name = this.paramSpec.name; + this.type = this.paramSpec.type; + this.short = this.paramSpec.short; + + if (this.short != null && !/[0-9A-Za-z]/.test(this.short)) { + throw new Error('\'short\' value must be a single alphanumeric digit.'); + } + + this.groupName = groupName; + if (this.groupName != null) { + if (this.paramSpec.option != null) { + throw new Error('Can\'t have a "option" property in a nested parameter'); + } + } + else { + if (this.paramSpec.option != null) { + this.groupName = (this.paramSpec.option === true) ? + l10n.lookup('canonDefaultGroupName') : + '' + this.paramSpec.option; + } + } + + if (!this.name) { + throw new Error('In ' + this.command.name + + ': all params must have a name'); + } + + var typeSpec = this.type; + this.type = types.createType(typeSpec); + if (this.type == null) { + console.error('Known types: ' + types.getTypeNames().join(', ')); + throw new Error('In ' + this.command.name + '/' + this.name + + ': can\'t find type for: ' + JSON.stringify(typeSpec)); + } + + // boolean parameters have an implicit defaultValue:false, which should + // not be changed. See the docs. + if (this.type.name === 'boolean' && + this.paramSpec.defaultValue !== undefined) { + throw new Error('In ' + this.command.name + '/' + this.name + + ': boolean parameters can not have a defaultValue.' + + ' Ignoring'); + } + + // All parameters that can only be set via a named parameter must have a + // non-undefined default value + if (!this.isPositionalAllowed && this.paramSpec.defaultValue === undefined && + this.type.getBlank == null && this.type.name !== 'boolean') { + throw new Error('In ' + this.command.name + '/' + this.name + + ': Missing defaultValue for optional parameter.'); + } + + if (this.paramSpec.defaultValue !== undefined) { + this.defaultValue = this.paramSpec.defaultValue; + } + else { + Object.defineProperty(this, 'defaultValue', { + get: function() { + return this.type.getBlank().value; + }, + enumerable: true + }); + } + + // Resolve the documentation + this.manual = lookup(this.paramSpec.manual); + this.description = lookup(this.paramSpec.description, 'canonDescNone'); + + // Is the user required to enter data for this parameter? (i.e. has + // defaultValue been set to something other than undefined) + // TODO: When the defaultValue comes from type.getBlank().value (see above) + // then perhaps we should set using something like + // isDataRequired = (type.getBlank().status !== VALID) + this.isDataRequired = (this.defaultValue === undefined); + + // Are we allowed to assign data to this parameter using positional + // parameters? + this.isPositionalAllowed = this.groupName == null; +} + +/** + * Does the given name uniquely identify this param (among the other params + * in this command) + * @param name The name to check + */ +Parameter.prototype.isKnownAs = function(name) { + return (name === '--' + this.name) || (name === '-' + this.short); +}; + +/** + * Reflect the paramSpec 'hidden' property (dynamically so it can change) + */ +Object.defineProperty(Parameter.prototype, 'hidden', { + get: function() { + return this.paramSpec.hidden; + }, + enumerable: true +}); + +/** + * JSON serializer that avoids non-serializable data + */ +Parameter.prototype.toJson = function() { + var json = { + name: this.name, + type: this.type.getSpec(this.command.name, this.name), + short: this.short + }; + + // Values do not need to be serializable, so we don't try. For the client + // side (which doesn't do any executing) we don't actually care what the + // default value is, just that it exists + if (this.paramSpec.defaultValue !== undefined) { + json.defaultValue = {}; + } + if (this.paramSpec.description != null) { + json.description = this.paramSpec.description; + } + if (this.paramSpec.manual != null) { + json.manual = this.paramSpec.manual; + } + if (this.paramSpec.hidden != null) { + json.hidden = this.paramSpec.hidden; + } + + // groupName can be set outside a paramSpec, (e.g. in grouped parameters) + // but it works like 'option' does so we use 'option' for groupNames + if (this.groupName != null || this.paramSpec.option != null) { + json.option = this.groupName || this.paramSpec.option; + } + + return json; +}; + +exports.Parameter = Parameter; + + +/** + * A store for a list of commands + * @param types Each command uses a set of Types to parse its parameters so the + * Commands container needs access to the list of available types. + * @param location String that, if set will force all commands to have a + * matching runAt property to be accepted + */ +function Commands(types, location) { + this.types = types; + this.location = location; + + // A lookup hash of our registered commands + this._commands = {}; + // A sorted list of command names, we regularly want them in order, so pre-sort + this._commandNames = []; + // A lookup of the original commandSpecs by command name + this._commandSpecs = {}; + + // Enable people to be notified of changes to the list of commands + this.onCommandsChange = util.createEvent('commands.onCommandsChange'); +} + +/** + * Add a command to the list of known commands. + * @param commandSpec The command and its metadata. + * @return The new command, or null if a location property has been set and the + * commandSpec doesn't have a matching runAt property. + */ +Commands.prototype.add = function(commandSpec) { + if (this.location != null && commandSpec.runAt != null && + commandSpec.runAt !== this.location) { + return; + } + + if (this._commands[commandSpec.name] != null) { + // Roughly commands.remove() without the event call, which we do later + delete this._commands[commandSpec.name]; + this._commandNames = this._commandNames.filter(function(test) { + return test !== commandSpec.name; + }); + } + + var command = new Command(this.types, commandSpec); + this._commands[commandSpec.name] = command; + this._commandNames.push(commandSpec.name); + this._commandNames.sort(); + + this._commandSpecs[commandSpec.name] = commandSpec; + + this.onCommandsChange(); + return command; +}; + +/** + * Remove an individual command. The opposite of Commands.add(). + * Removing a non-existent command is a no-op. + * @param commandOrName Either a command name or the command itself. + * @return true if a command was removed, false otherwise. + */ +Commands.prototype.remove = function(commandOrName) { + var name = typeof commandOrName === 'string' ? + commandOrName : + commandOrName.name; + + if (!this._commands[name]) { + return false; + } + + // See start of commands.add if changing this code + delete this._commands[name]; + delete this._commandSpecs[name]; + this._commandNames = this._commandNames.filter(function(test) { + return test !== name; + }); + + this.onCommandsChange(); + return true; +}; + +/** + * Retrieve a command by name + * @param name The name of the command to retrieve + */ +Commands.prototype.get = function(name) { + // '|| undefined' is to silence 'reference to undefined property' warnings + return this._commands[name] || undefined; +}; + +/** + * Get an array of all the registered commands. + */ +Commands.prototype.getAll = function() { + return Object.keys(this._commands).map(function(name) { + return this._commands[name]; + }, this); +}; + +/** + * Get access to the stored commandMetaDatas (i.e. before they were made into + * instances of Command/Parameters) so we can remote them. + * @param customProps Array of strings containing additional properties which, + * if specified in the command spec, will be included in the JSON. Normally we + * transfer only the properties required for GCLI to function. + */ +Commands.prototype.getCommandSpecs = function(customProps) { + var commandSpecs = []; + + Object.keys(this._commands).forEach(function(name) { + var command = this._commands[name]; + if (!command.noRemote) { + commandSpecs.push(command.toJson(customProps)); + } + }.bind(this)); + + return commandSpecs; +}; + +/** + * Add a set of commands that are executed somewhere else, optionally with a + * command prefix to distinguish these commands from a local set of commands. + * @param commandSpecs Presumably as obtained from getCommandSpecs + * @param remoter Function to call on exec of a new remote command. This is + * defined just like an exec function (i.e. that takes args/context as params + * and returns a promise) with one extra feature, that the context includes a + * 'commandName' property that contains the original command name. + * @param prefix The name prefix that we assign to all command names + * @param to URL-like string that describes where the commands are executed. + * This is to complete the parent command description. + */ +Commands.prototype.addProxyCommands = function(commandSpecs, remoter, prefix, to) { + if (prefix != null) { + if (this._commands[prefix] != null) { + throw new Error(l10n.lookupFormat('canonProxyExists', [ prefix ])); + } + + // We need to add the parent command so all the commands from the other + // system have a parent + this.add({ + name: prefix, + isProxy: true, + description: l10n.lookupFormat('canonProxyDesc', [ to ]), + manual: l10n.lookupFormat('canonProxyManual', [ to ]) + }); + } + + commandSpecs.forEach(function(commandSpec) { + var originalName = commandSpec.name; + if (!commandSpec.isParent) { + commandSpec.exec = function(args, context) { + context.commandName = originalName; + return remoter(args, context); + }.bind(this); + } + + if (prefix != null) { + commandSpec.name = prefix + ' ' + commandSpec.name; + } + commandSpec.isProxy = true; + this.add(commandSpec); + }.bind(this)); +}; + +/** + * Remove a set of commands added with addProxyCommands. + * @param prefix The name prefix that we assign to all command names + */ +Commands.prototype.removeProxyCommands = function(prefix) { + var toRemove = []; + Object.keys(this._commandSpecs).forEach(function(name) { + if (name.indexOf(prefix) === 0) { + toRemove.push(name); + } + }.bind(this)); + + var removed = []; + toRemove.forEach(function(name) { + var command = this.get(name); + if (command.isProxy) { + this.remove(name); + removed.push(name); + } + else { + console.error('Skipping removal of \'' + name + + '\' because it is not a proxy command.'); + } + }.bind(this)); + + return removed; +}; + +exports.Commands = Commands; + +/** + * CommandOutputManager stores the output objects generated by executed + * commands. + * + * CommandOutputManager is exposed to the the outside world and could (but + * shouldn't) be used before gcli.startup() has been called. + * This could should be defensive to that where possible, and we should + * certainly document if the use of it or similar will fail if used too soon. + */ +function CommandOutputManager() { + this.onOutput = util.createEvent('CommandOutputManager.onOutput'); +} + +exports.CommandOutputManager = CommandOutputManager; |