Files
UXP-Fixed/devtools/shared/gcli/source/lib/gcli/commands/commands.js
T
2018-02-04 12:02:19 +01:00

571 lines
17 KiB
JavaScript

/*
* 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 only care whether default value is
// undefined, null, or something else.
if (this.paramSpec.defaultValue !== undefined) {
json.defaultValue = (this.paramSpec.defaultValue === null) ? null : {};
}
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;