diff options
Diffstat (limited to 'devtools/shared/gcli/source/lib')
71 files changed, 15005 insertions, 0 deletions
diff --git a/devtools/shared/gcli/source/lib/gcli/cli.js b/devtools/shared/gcli/source/lib/gcli/cli.js new file mode 100644 index 000000000..4b7c115e2 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/cli.js @@ -0,0 +1,2209 @@ +/* + * 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 host = require('./util/host'); +var l10n = require('./util/l10n'); + +var view = require('./ui/view'); +var Parameter = require('./commands/commands').Parameter; +var CommandOutputManager = require('./commands/commands').CommandOutputManager; + +var Status = require('./types/types').Status; +var Conversion = require('./types/types').Conversion; +var commandModule = require('./types/command'); +var selectionModule = require('./types/selection'); + +var Argument = require('./types/types').Argument; +var ArrayArgument = require('./types/types').ArrayArgument; +var NamedArgument = require('./types/types').NamedArgument; +var TrueNamedArgument = require('./types/types').TrueNamedArgument; +var MergedArgument = require('./types/types').MergedArgument; +var ScriptArgument = require('./types/types').ScriptArgument; + +var RESOLVED = Promise.resolve(undefined); + +// Helper to produce a `deferred` object +// using DOM Promise +function defer() { + let resolve, reject; + let p = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { + promise: p, + resolve: resolve, + reject: reject + }; +} + +/** + * This is a list of the known command line components to enable certain + * privileged commands to alter parts of a running command line. It is an array + * of objects shaped like: + * { conversionContext:..., executionContext:..., mapping:... } + * So lookup is O(n) where 'n' is the number of command lines. + */ +var instances = []; + +/** + * An indexOf that looks-up both types of context + */ +function instanceIndex(context) { + for (var i = 0; i < instances.length; i++) { + var instance = instances[i]; + if (instance.conversionContext === context || + instance.executionContext === context) { + return i; + } + } + return -1; +} + +/** + * findInstance gets access to a Terminal object given a conversionContext or + * an executionContext (it doesn't have to be a terminal object, just whatever + * was passed into addMapping() + */ +exports.getMapping = function(context) { + var index = instanceIndex(context); + if (index === -1) { + console.log('Missing mapping for context: ', context); + console.log('Known contexts: ', instances); + throw new Error('Missing mapping for context'); + } + return instances[index].mapping; +}; + +/** + * Add a requisition context->terminal mapping + */ +var addMapping = function(requisition) { + if (instanceIndex(requisition.conversionContext) !== -1) { + throw new Error('Remote existing mapping before adding a new one'); + } + + instances.push({ + conversionContext: requisition.conversionContext, + executionContext: requisition.executionContext, + mapping: { requisition: requisition } + }); +}; + +/** + * Remove a requisition context->terminal mapping + */ +var removeMapping = function(requisition) { + var index = instanceIndex(requisition.conversionContext); + instances.splice(index, 1); +}; + +/** + * Assignment is a link between a parameter and the data for that parameter. + * The data for the parameter is available as in the preferred type and as + * an Argument for the CLI. + * <p>We also record validity information where applicable. + * <p>For values, null and undefined have distinct definitions. null means + * that a value has been provided, undefined means that it has not. + * Thus, null is a valid default value, and common because it identifies an + * parameter that is optional. undefined means there is no value from + * the command line. + * @constructor + */ +function Assignment(param) { + // The parameter that we are assigning to + this.param = param; + this.conversion = undefined; +} + +/** + * Easy accessor for conversion.arg. + * This is a read-only property because writes to arg should be done through + * the 'conversion' property. + */ +Object.defineProperty(Assignment.prototype, 'arg', { + get: function() { + return this.conversion == null ? undefined : this.conversion.arg; + }, + enumerable: true +}); + +/** + * Easy accessor for conversion.value. + * This is a read-only property because writes to value should be done through + * the 'conversion' property. + */ +Object.defineProperty(Assignment.prototype, 'value', { + get: function() { + return this.conversion == null ? undefined : this.conversion.value; + }, + enumerable: true +}); + +/** + * Easy (and safe) accessor for conversion.message + */ +Object.defineProperty(Assignment.prototype, 'message', { + get: function() { + if (this.conversion != null && this.conversion.message) { + return this.conversion.message; + } + // ERROR conversions have messages, VALID conversions don't need one, so + // we just need to consider INCOMPLETE conversions. + if (this.getStatus() === Status.INCOMPLETE) { + return l10n.lookupFormat('cliIncompleteParam', [ this.param.name ]); + } + return ''; + }, + enumerable: true +}); + +/** + * Easy (and safe) accessor for conversion.getPredictions() + * @return An array of objects with name and value elements. For example: + * [ { name:'bestmatch', value:foo1 }, { name:'next', value:foo2 }, ... ] + */ +Assignment.prototype.getPredictions = function(context) { + return this.conversion == null ? [] : this.conversion.getPredictions(context); +}; + +/** + * Accessor for a prediction by index. + * This is useful above <tt>getPredictions()[index]</tt> because it normalizes + * index to be within the bounds of the predictions, which means that the UI + * can maintain an index of which prediction to choose without caring how many + * predictions there are. + * @param rank The index of the prediction to choose + */ +Assignment.prototype.getPredictionRanked = function(context, rank) { + if (rank == null) { + rank = 0; + } + + if (this.isInName()) { + return Promise.resolve(undefined); + } + + return this.getPredictions(context).then(function(predictions) { + if (predictions.length === 0) { + return undefined; + } + + rank = rank % predictions.length; + if (rank < 0) { + rank = predictions.length + rank; + } + return predictions[rank]; + }.bind(this)); +}; + +/** + * Some places want to take special action if we are in the name part of a + * named argument (i.e. the '--foo' bit). + * Currently this does not take actual cursor position into account, it just + * assumes that the cursor is at the end. In the future we will probably want + * to take this into account. + */ +Assignment.prototype.isInName = function() { + return this.conversion.arg.type === 'NamedArgument' && + this.conversion.arg.prefix.slice(-1) !== ' '; +}; + +/** + * Work out what the status of the current conversion is which involves looking + * not only at the conversion, but also checking if data has been provided + * where it should. + * @param arg For assignments with multiple args (e.g. array assignments) we + * can narrow the search for status to a single argument. + */ +Assignment.prototype.getStatus = function(arg) { + if (this.param.isDataRequired && !this.conversion.isDataProvided()) { + return Status.INCOMPLETE; + } + + // Selection/Boolean types with a defined range of values will say that + // '' is INCOMPLETE, but the parameter may be optional, so we don't ask + // if the user doesn't need to enter something and hasn't done so. + if (!this.param.isDataRequired && this.arg.type === 'BlankArgument') { + return Status.VALID; + } + + return this.conversion.getStatus(arg); +}; + +/** + * Helper when we're rebuilding command lines. + */ +Assignment.prototype.toString = function() { + return this.conversion.toString(); +}; + +/** + * For test/debug use only. The output from this function is subject to wanton + * random change without notice, and should not be relied upon to even exist + * at some later date. + */ +Object.defineProperty(Assignment.prototype, '_summaryJson', { + get: function() { + return { + param: this.param.name + '/' + this.param.type.name, + defaultValue: this.param.defaultValue, + arg: this.conversion.arg._summaryJson, + value: this.value, + message: this.message, + status: this.getStatus().toString() + }; + }, + enumerable: true +}); + +exports.Assignment = Assignment; + + +/** + * How to dynamically execute JavaScript code + */ +var customEval = eval; + +/** + * Setup a function to be called in place of 'eval', generally for security + * reasons + */ +exports.setEvalFunction = function(newCustomEval) { + customEval = newCustomEval; +}; + +/** + * Remove the binding done by setEvalFunction(). + * We purposely set customEval to undefined rather than to 'eval' because there + * is an implication of setEvalFunction that we're in a security sensitive + * situation. What if we can trick GCLI into calling unsetEvalFunction() at the + * wrong time? + * So to properly undo the effects of setEvalFunction(), you need to call + * setEvalFunction(eval) rather than unsetEvalFunction(), however the latter is + * preferred in most cases. + */ +exports.unsetEvalFunction = function() { + customEval = undefined; +}; + +/** + * 'eval' command + */ +var evalCmd = { + item: 'command', + name: '{', + params: [ + { + name: 'javascript', + type: 'javascript', + description: '' + } + ], + hidden: true, + description: { key: 'cliEvalJavascript' }, + exec: function(args, context) { + var reply = customEval(args.javascript); + return context.typedData(typeof reply, reply); + }, + isCommandRegexp: /^\s*\{\s*/ +}; + +exports.items = [ evalCmd ]; + +/** + * This is a special assignment to reflect the command itself. + */ +function CommandAssignment(requisition) { + var commandParamMetadata = { + name: '__command', + type: { name: 'command', allowNonExec: false } + }; + // This is a hack so that rather than reply with a generic description of the + // command assignment, we reply with the description of the assigned command, + // (using a generic term if there is no assigned command) + var self = this; + Object.defineProperty(commandParamMetadata, 'description', { + get: function() { + var value = self.value; + return value && value.description ? + value.description : + 'The command to execute'; + }, + enumerable: true + }); + this.param = new Parameter(requisition.system.types, commandParamMetadata); +} + +CommandAssignment.prototype = Object.create(Assignment.prototype); + +CommandAssignment.prototype.getStatus = function(arg) { + return Status.combine( + Assignment.prototype.getStatus.call(this, arg), + this.conversion.value && this.conversion.value.exec ? + Status.VALID : Status.INCOMPLETE + ); +}; + +exports.CommandAssignment = CommandAssignment; + + +/** + * Special assignment used when ignoring parameters that don't have a home + */ +function UnassignedAssignment(requisition, arg) { + var isIncompleteName = (arg.text.charAt(0) === '-'); + this.param = new Parameter(requisition.system.types, { + name: '__unassigned', + description: l10n.lookup('cliOptions'), + type: { + name: 'param', + requisition: requisition, + isIncompleteName: isIncompleteName + } + }); + + // It would be nice to do 'conversion = parm.type.parse(arg, ...)' except + // that type.parse returns a promise (even though it's synchronous in this + // case) + if (isIncompleteName) { + var lookup = commandModule.getDisplayedParamLookup(requisition); + var predictions = selectionModule.findPredictions(arg, lookup); + this.conversion = selectionModule.convertPredictions(arg, predictions); + } + else { + var message = l10n.lookup('cliUnusedArg'); + this.conversion = new Conversion(undefined, arg, Status.ERROR, message); + } + + this.conversion.assignment = this; +} + +UnassignedAssignment.prototype = Object.create(Assignment.prototype); + +UnassignedAssignment.prototype.getStatus = function(arg) { + return this.conversion.getStatus(); +}; + +var logErrors = true; + +/** + * Allow tests that expect failures to avoid clogging up the console + */ +Object.defineProperty(exports, 'logErrors', { + get: function() { + return logErrors; + }, + set: function(val) { + logErrors = val; + }, + enumerable: true +}); + +/** + * A Requisition collects the information needed to execute a command. + * + * (For a definition of the term, see http://en.wikipedia.org/wiki/Requisition) + * This term is used because carries the notion of a work-flow, or process to + * getting the information to execute a command correct. + * There is little point in a requisition for parameter-less commands because + * there is no information to collect. A Requisition is a collection of + * assignments of values to parameters, each handled by an instance of + * Assignment. + * + * @param system Allows access to the various plug-in points in GCLI. At a + * minimum it must contain commands and types objects. + * @param options A set of options to customize how GCLI is used. Includes: + * - environment An optional opaque object passed to commands in the + * Execution Context. + * - document A DOM Document passed to commands using the Execution Context in + * order to allow creation of DOM nodes. If missing Requisition will use the + * global 'document', or leave undefined. + * - commandOutputManager A custom commandOutputManager to which output should + * be sent + * @constructor + */ +function Requisition(system, options) { + options = options || {}; + + this.environment = options.environment || {}; + this.document = options.document; + if (this.document == null) { + try { + this.document = document; + } + catch (ex) { + // Ignore + } + } + + this.commandOutputManager = options.commandOutputManager || new CommandOutputManager(); + this.system = system; + + this.shell = { + cwd: '/', // Where we store the current working directory + env: {} // Where we store the current environment + }; + + // The command that we are about to execute. + // @see setCommandConversion() + this.commandAssignment = new CommandAssignment(this); + + // The object that stores of Assignment objects that we are filling out. + // The Assignment objects are stored under their param.name for named + // lookup. Note: We make use of the property of Javascript objects that + // they are not just hashmaps, but linked-list hashmaps which iterate in + // insertion order. + // _assignments excludes the commandAssignment. + this._assignments = {}; + + // The count of assignments. Excludes the commandAssignment + this.assignmentCount = 0; + + // Used to store cli arguments in the order entered on the cli + this._args = []; + + // Used to store cli arguments that were not assigned to parameters + this._unassigned = []; + + // Changes can be asynchronous, when one update starts before another + // finishes we abandon the former change + this._nextUpdateId = 0; + + // We can set a prefix to typed commands to make it easier to focus on + // Allowing us to type "add -a; commit" in place of "git add -a; git commit" + this.prefix = ''; + + addMapping(this); + this._setBlankAssignment(this.commandAssignment); + + // If a command calls context.update then the UI needs some way to be + // informed of the change + this.onExternalUpdate = util.createEvent('Requisition.onExternalUpdate'); +} + +/** + * Avoid memory leaks + */ +Requisition.prototype.destroy = function() { + this.document = undefined; + this.environment = undefined; + removeMapping(this); +}; + +/** + * If we're about to make an asynchronous change when other async changes could + * overtake this one, then we want to be able to bail out if overtaken. The + * value passed back from beginChange should be passed to endChangeCheckOrder + * on completion of calculation, before the results are applied in order to + * check that the calculation has not been overtaken + */ +Requisition.prototype._beginChange = function() { + var updateId = this._nextUpdateId; + this._nextUpdateId++; + return updateId; +}; + +/** + * Check to see if another change has started since updateId started. + * This allows us to bail out of an update. + * It's hard to make updates atomic because until you've responded to a parse + * of the command argument, you don't know how to parse the arguments to that + * command. + */ +Requisition.prototype._isChangeCurrent = function(updateId) { + return updateId + 1 === this._nextUpdateId; +}; + +/** + * See notes on beginChange + */ +Requisition.prototype._endChangeCheckOrder = function(updateId) { + if (updateId + 1 !== this._nextUpdateId) { + // An update that started after we did has already finished, so our + // changes are out of date. Abandon further work. + return false; + } + + return true; +}; + +var legacy = false; + +/** + * Functions and data related to the execution of a command + */ +Object.defineProperty(Requisition.prototype, 'executionContext', { + get: function() { + if (this._executionContext == null) { + this._executionContext = { + defer: defer, + typedData: function(type, data) { + return { + isTypedData: true, + data: data, + type: type + }; + }, + getArgsObject: this.getArgsObject.bind(this) + }; + + // Alias requisition so we're clear about what's what + var requisition = this; + Object.defineProperty(this._executionContext, 'prefix', { + get: function() { return requisition.prefix; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'typed', { + get: function() { return requisition.toString(); }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'environment', { + get: function() { return requisition.environment; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'shell', { + get: function() { return requisition.shell; }, + enumerable: true + }); + Object.defineProperty(this._executionContext, 'system', { + get: function() { return requisition.system; }, + enumerable: true + }); + + this._executionContext.updateExec = this._contextUpdateExec.bind(this); + + if (legacy) { + this._executionContext.createView = view.createView; + this._executionContext.exec = this.exec.bind(this); + this._executionContext.update = this._contextUpdate.bind(this); + + Object.defineProperty(this._executionContext, 'document', { + get: function() { return requisition.document; }, + enumerable: true + }); + } + } + + return this._executionContext; + }, + enumerable: true +}); + +/** + * Functions and data related to the conversion of the output of a command + */ +Object.defineProperty(Requisition.prototype, 'conversionContext', { + get: function() { + if (this._conversionContext == null) { + this._conversionContext = { + defer: defer, + + createView: view.createView, + exec: this.exec.bind(this), + update: this._contextUpdate.bind(this), + updateExec: this._contextUpdateExec.bind(this) + }; + + // Alias requisition so we're clear about what's what + var requisition = this; + + Object.defineProperty(this._conversionContext, 'document', { + get: function() { return requisition.document; }, + enumerable: true + }); + Object.defineProperty(this._conversionContext, 'environment', { + get: function() { return requisition.environment; }, + enumerable: true + }); + Object.defineProperty(this._conversionContext, 'system', { + get: function() { return requisition.system; }, + enumerable: true + }); + } + + return this._conversionContext; + }, + enumerable: true +}); + +/** + * Assignments have an order, so we need to store them in an array. + * But we also need named access ... + * @return The found assignment, or undefined, if no match was found + */ +Requisition.prototype.getAssignment = function(nameOrNumber) { + var name = (typeof nameOrNumber === 'string') ? + nameOrNumber : + Object.keys(this._assignments)[nameOrNumber]; + return this._assignments[name] || undefined; +}; + +/** + * Where parameter name == assignment names - they are the same + */ +Requisition.prototype.getParameterNames = function() { + return Object.keys(this._assignments); +}; + +/** + * The overall status is the most severe status. + * There is no such thing as an INCOMPLETE overall status because the + * definition of INCOMPLETE takes into account the cursor position to say 'this + * isn't quite ERROR because the user can fix it by typing', however overall, + * this is still an error status. + */ +Object.defineProperty(Requisition.prototype, 'status', { + get: function() { + var status = Status.VALID; + if (this._unassigned.length !== 0) { + var isAllIncomplete = true; + this._unassigned.forEach(function(assignment) { + if (!assignment.param.type.isIncompleteName) { + isAllIncomplete = false; + } + }); + status = isAllIncomplete ? Status.INCOMPLETE : Status.ERROR; + } + + this.getAssignments(true).forEach(function(assignment) { + var assignStatus = assignment.getStatus(); + if (assignStatus > status) { + status = assignStatus; + } + }, this); + if (status === Status.INCOMPLETE) { + status = Status.ERROR; + } + return status; + }, + enumerable : true +}); + +/** + * If ``requisition.status != VALID`` message then return a string which + * best describes what is wrong. Generally error messages are delivered by + * looking at the error associated with the argument at the cursor, but there + * are times when you just want to say 'tell me the worst'. + * If ``requisition.status != VALID`` then return ``null``. + */ +Requisition.prototype.getStatusMessage = function() { + if (this.commandAssignment.getStatus() !== Status.VALID) { + return l10n.lookupFormat('cliUnknownCommand2', + [ this.commandAssignment.arg.text ]); + } + + var assignments = this.getAssignments(); + for (var i = 0; i < assignments.length; i++) { + if (assignments[i].getStatus() !== Status.VALID) { + return assignments[i].message; + } + } + + if (this._unassigned.length !== 0) { + return l10n.lookup('cliUnusedArg'); + } + + return null; +}; + +/** + * Extract the names and values of all the assignments, and return as + * an object. + */ +Requisition.prototype.getArgsObject = function() { + var args = {}; + this.getAssignments().forEach(function(assignment) { + args[assignment.param.name] = assignment.conversion.isDataProvided() ? + assignment.value : + assignment.param.defaultValue; + }, this); + return args; +}; + +/** + * Access the arguments as an array. + * @param includeCommand By default only the parameter arguments are + * returned unless (includeCommand === true), in which case the list is + * prepended with commandAssignment.arg + */ +Requisition.prototype.getAssignments = function(includeCommand) { + var assignments = []; + if (includeCommand === true) { + assignments.push(this.commandAssignment); + } + Object.keys(this._assignments).forEach(function(name) { + assignments.push(this.getAssignment(name)); + }, this); + return assignments; +}; + +/** + * There are a few places where we need to know what the 'next thing' is. What + * is the user going to be filling out next (assuming they don't enter a named + * argument). The next argument is the first in line that is both blank, and + * that can be filled in positionally. + * @return The next assignment to be used, or null if all the positional + * parameters have values. + */ +Requisition.prototype._getFirstBlankPositionalAssignment = function() { + var reply = null; + Object.keys(this._assignments).some(function(name) { + var assignment = this.getAssignment(name); + if (assignment.arg.type === 'BlankArgument' && + assignment.param.isPositionalAllowed) { + reply = assignment; + return true; // i.e. break + } + return false; + }, this); + return reply; +}; + +/** + * The update process is asynchronous, so there is (unavoidably) a window + * where we've worked out the command but don't yet understand all the params. + * If we try to do things to a requisition in this window we may get + * inconsistent results. Asynchronous promises have made the window bigger. + * The only time we've seen this in practice is during focus events due to + * clicking on a shortcut. The focus want to check the cursor position while + * the shortcut is updating the command line. + * This function allows us to detect and back out of this problem. + * We should be able to remove this function when all the state in a + * requisition can be encapsulated and updated atomically. + */ +Requisition.prototype.isUpToDate = function() { + if (!this._args) { + return false; + } + for (var i = 0; i < this._args.length; i++) { + if (this._args[i].assignment == null) { + return false; + } + } + return true; +}; + +/** + * Look through the arguments attached to our assignments for the assignment + * at the given position. + * @param {number} cursor The cursor position to query + */ +Requisition.prototype.getAssignmentAt = function(cursor) { + // We short circuit this one because we may have no args, or no args with + // any size and the alg below only finds arguments with size. + if (cursor === 0) { + return this.commandAssignment; + } + + var assignForPos = []; + var i, j; + for (i = 0; i < this._args.length; i++) { + var arg = this._args[i]; + var assignment = arg.assignment; + + // prefix and text are clearly part of the argument + for (j = 0; j < arg.prefix.length; j++) { + assignForPos.push(assignment); + } + for (j = 0; j < arg.text.length; j++) { + assignForPos.push(assignment); + } + + // suffix is part of the argument only if this is a named parameter, + // otherwise it looks forwards + if (arg.assignment.arg.type === 'NamedArgument') { + // leave the argument as it is + } + else if (this._args.length > i + 1) { + // first to the next argument + assignment = this._args[i + 1].assignment; + } + else { + // then to the first blank positional parameter, leaving 'as is' if none + var nextAssignment = this._getFirstBlankPositionalAssignment(); + if (nextAssignment != null) { + assignment = nextAssignment; + } + } + + for (j = 0; j < arg.suffix.length; j++) { + assignForPos.push(assignment); + } + } + + // Possible shortcut, we don't really need to go through all the args + // to work out the solution to this + + return assignForPos[cursor - 1]; +}; + +/** + * Extract a canonical version of the input + * @return a promise of a string which is the canonical version of what was + * typed + */ +Requisition.prototype.toCanonicalString = function() { + var cmd = this.commandAssignment.value ? + this.commandAssignment.value.name : + this.commandAssignment.arg.text; + + // Canonically, if we've opened with a { then we should have a } to close + var lineSuffix = ''; + if (cmd === '{') { + var scriptSuffix = this.getAssignment(0).arg.suffix; + lineSuffix = (scriptSuffix.indexOf('}') === -1) ? ' }' : ''; + } + + var ctx = this.executionContext; + + // First stringify all the arguments + var argPromise = util.promiseEach(this.getAssignments(), function(assignment) { + // Bug 664377: This will cause problems if there is a non-default value + // after a default value. Also we need to decide when to use + // named parameters in place of positional params. Both can wait. + if (assignment.value === assignment.param.defaultValue) { + return ''; + } + + var val = assignment.param.type.stringify(assignment.value, ctx); + return Promise.resolve(val).then(function(str) { + return ' ' + str; + }.bind(this)); + }.bind(this)); + + return argPromise.then(function(strings) { + return cmd + strings.join('') + lineSuffix; + }.bind(this)); +}; + +/** + * Reconstitute the input from the args + */ +Requisition.prototype.toString = function() { + if (!this._args) { + throw new Error('toString requires a command line. See source.'); + } + + return this._args.map(function(arg) { + return arg.toString(); + }).join(''); +}; + +/** + * For test/debug use only. The output from this function is subject to wanton + * random change without notice, and should not be relied upon to even exist + * at some later date. + */ +Object.defineProperty(Requisition.prototype, '_summaryJson', { + get: function() { + var summary = { + $args: this._args.map(function(arg) { + return arg._summaryJson; + }), + _command: this.commandAssignment._summaryJson, + _unassigned: this._unassigned.forEach(function(assignment) { + return assignment._summaryJson; + }) + }; + + Object.keys(this._assignments).forEach(function(name) { + summary[name] = this.getAssignment(name)._summaryJson; + }.bind(this)); + + return summary; + }, + enumerable: true +}); + +/** + * When any assignment changes, we might need to update the _args array to + * match and inform people of changes to the typed input text. + */ +Requisition.prototype._setAssignmentInternal = function(assignment, conversion) { + var oldConversion = assignment.conversion; + + assignment.conversion = conversion; + assignment.conversion.assignment = assignment; + + // Do nothing if the conversion is unchanged + if (assignment.conversion.equals(oldConversion)) { + if (assignment === this.commandAssignment) { + this._setBlankArguments(); + } + return; + } + + // When the command changes, we need to keep a bunch of stuff in sync + if (assignment === this.commandAssignment) { + this._assignments = {}; + + var command = this.commandAssignment.value; + if (command) { + for (var i = 0; i < command.params.length; i++) { + var param = command.params[i]; + var newAssignment = new Assignment(param); + this._setBlankAssignment(newAssignment); + this._assignments[param.name] = newAssignment; + } + } + this.assignmentCount = Object.keys(this._assignments).length; + } +}; + +/** + * Internal function to alter the given assignment using the given arg. + * @param assignment The assignment to alter + * @param arg The new value for the assignment. An instance of Argument, or an + * instance of Conversion, or null to set the blank value. + * @param options There are a number of ways to customize how the assignment + * is made, including: + * - internal: (default:false) External updates are required to do more work, + * including adjusting the args in this requisition to stay in sync. + * On the other hand non internal changes use beginChange to back out of + * changes when overtaken asynchronously. + * Setting internal:true effectively means this is being called as part of + * the update process. + * - matchPadding: (default:false) Alter the whitespace on the prefix and + * suffix of the new argument to match that of the old argument. This only + * makes sense with internal=false + * @return A promise that resolves to undefined when the assignment is complete + */ +Requisition.prototype.setAssignment = function(assignment, arg, options) { + options = options || {}; + if (!options.internal) { + var originalArgs = assignment.arg.getArgs(); + + // Update the args array + var replacementArgs = arg.getArgs(); + var maxLen = Math.max(originalArgs.length, replacementArgs.length); + for (var i = 0; i < maxLen; i++) { + // If there are no more original args, or if the original arg was blank + // (i.e. not typed by the user), we'll just need to add at the end + if (i >= originalArgs.length || originalArgs[i].type === 'BlankArgument') { + this._args.push(replacementArgs[i]); + continue; + } + + var index = this._args.indexOf(originalArgs[i]); + if (index === -1) { + console.error('Couldn\'t find ', originalArgs[i], ' in ', this._args); + throw new Error('Couldn\'t find ' + originalArgs[i]); + } + + // If there are no more replacement args, we just remove the original args + // Otherwise swap original args and replacements + if (i >= replacementArgs.length) { + this._args.splice(index, 1); + } + else { + if (options.matchPadding) { + if (replacementArgs[i].prefix.length === 0 && + this._args[index].prefix.length !== 0) { + replacementArgs[i].prefix = this._args[index].prefix; + } + if (replacementArgs[i].suffix.length === 0 && + this._args[index].suffix.length !== 0) { + replacementArgs[i].suffix = this._args[index].suffix; + } + } + this._args[index] = replacementArgs[i]; + } + } + } + + var updateId = options.internal ? null : this._beginChange(); + + var setAssignmentInternal = function(conversion) { + if (options.internal || this._isChangeCurrent(updateId)) { + this._setAssignmentInternal(assignment, conversion); + } + + if (!options.internal) { + this._endChangeCheckOrder(updateId); + } + + return Promise.resolve(undefined); + }.bind(this); + + if (arg == null) { + var blank = assignment.param.type.getBlank(this.executionContext); + return setAssignmentInternal(blank); + } + + if (typeof arg.getStatus === 'function') { + // It's not really an arg, it's a conversion already + return setAssignmentInternal(arg); + } + + var parsed = assignment.param.type.parse(arg, this.executionContext); + return parsed.then(setAssignmentInternal); +}; + +/** + * Reset an assignment to its default value. + * For internal use only. + * Happens synchronously. + */ +Requisition.prototype._setBlankAssignment = function(assignment) { + var blank = assignment.param.type.getBlank(this.executionContext); + this._setAssignmentInternal(assignment, blank); +}; + +/** + * Reset all the assignments to their default values. + * For internal use only. + * Happens synchronously. + */ +Requisition.prototype._setBlankArguments = function() { + this.getAssignments().forEach(this._setBlankAssignment.bind(this)); +}; + +/** + * Input trace gives us an array of Argument tracing objects, one for each + * character in the typed input, from which we can derive information about how + * to display this typed input. It's a bit like toString on steroids. + * <p> + * The returned object has the following members:<ul> + * <li>character: The character to which this arg trace refers. + * <li>arg: The Argument to which this character is assigned. + * <li>part: One of ['prefix'|'text'|suffix'] - how was this char understood + * </ul> + * <p> + * The Argument objects are as output from tokenize() rather than as applied + * to Assignments by _assign() (i.e. they are not instances of NamedArgument, + * ArrayArgument, etc). + * <p> + * To get at the arguments applied to the assignments simply call + * <tt>arg.assignment.arg</tt>. If <tt>arg.assignment.arg !== arg</tt> then + * the arg applied to the assignment will contain the original arg. + * See _assign() for details. + */ +Requisition.prototype.createInputArgTrace = function() { + if (!this._args) { + throw new Error('createInputMap requires a command line. See source.'); + } + + var args = []; + var i; + this._args.forEach(function(arg) { + for (i = 0; i < arg.prefix.length; i++) { + args.push({ arg: arg, character: arg.prefix[i], part: 'prefix' }); + } + for (i = 0; i < arg.text.length; i++) { + args.push({ arg: arg, character: arg.text[i], part: 'text' }); + } + for (i = 0; i < arg.suffix.length; i++) { + args.push({ arg: arg, character: arg.suffix[i], part: 'suffix' }); + } + }); + + return args; +}; + +/** + * If the last character is whitespace then things that we suggest to add to + * the end don't need a space prefix. + * While this is quite a niche function, it has 2 benefits: + * - it's more correct because we can distinguish between final whitespace that + * is part of an unclosed string, and parameter separating whitespace. + * - also it's faster than toString() the whole thing and checking the end char + * @return true iff the last character is interpreted as parameter separating + * whitespace + */ +Requisition.prototype.typedEndsWithSeparator = function() { + if (!this._args) { + throw new Error('typedEndsWithSeparator requires a command line. See source.'); + } + + if (this._args.length === 0) { + return false; + } + + // This is not as easy as doing (this.toString().slice(-1) === ' ') + // See the doc comments above; We're checking for separators, not spaces + var lastArg = this._args.slice(-1)[0]; + if (lastArg.suffix.slice(-1) === ' ') { + return true; + } + + return lastArg.text === '' && lastArg.suffix === '' + && lastArg.prefix.slice(-1) === ' '; +}; + +/** + * Return an array of Status scores so we can create a marked up + * version of the command line input. + * @param cursor We only take a status of INCOMPLETE to be INCOMPLETE when the + * cursor is actually in the argument. Otherwise it's an error. + * @return Array of objects each containing <tt>status</tt> property and a + * <tt>string</tt> property containing the characters to which the status + * applies. Concatenating the strings in order gives the original input. + */ +Requisition.prototype.getInputStatusMarkup = function(cursor) { + var argTraces = this.createInputArgTrace(); + // Generally the 'argument at the cursor' is the argument before the cursor + // unless it is before the first char, in which case we take the first. + cursor = cursor === 0 ? 0 : cursor - 1; + var cTrace = argTraces[cursor]; + + var markup = []; + for (var i = 0; i < argTraces.length; i++) { + var argTrace = argTraces[i]; + var arg = argTrace.arg; + var status = Status.VALID; + // When things get very async we can get here while something else is + // doing an update, in which case arg.assignment == null, so we check first + if (argTrace.part === 'text' && arg.assignment != null) { + status = arg.assignment.getStatus(arg); + // Promote INCOMPLETE to ERROR ... + if (status === Status.INCOMPLETE) { + // If the cursor is in the prefix or suffix of an argument then we + // don't consider it in the argument for the purposes of preventing + // the escalation to ERROR. However if this is a NamedArgument, then we + // allow the suffix (as space between 2 parts of the argument) to be in. + // We use arg.assignment.arg not arg because we're looking at the arg + // that got put into the assignment not as returned by tokenize() + var isNamed = (cTrace.arg.assignment.arg.type === 'NamedArgument'); + var isInside = cTrace.part === 'text' || + (isNamed && cTrace.part === 'suffix'); + if (arg.assignment !== cTrace.arg.assignment || !isInside) { + // And if we're not in the command + if (!(arg.assignment instanceof CommandAssignment)) { + status = Status.ERROR; + } + } + } + } + + markup.push({ status: status, string: argTrace.character }); + } + + // De-dupe: merge entries where 2 adjacent have same status + i = 0; + while (i < markup.length - 1) { + if (markup[i].status === markup[i + 1].status) { + markup[i].string += markup[i + 1].string; + markup.splice(i + 1, 1); + } + else { + i++; + } + } + + return markup; +}; + +/** + * Describe the state of the current input in a way that allows display of + * predictions and completion hints + * @param start The location of the cursor + * @param rank The index of the chosen prediction + * @return A promise of an object containing the following properties: + * - statusMarkup: An array of Status scores so we can create a marked up + * version of the command line input. See getInputStatusMarkup() for details + * - unclosedJs: Is the entered command a JS command with no closing '}'? + * - directTabText: A promise of the text that we *add* to the command line + * when TAB is pressed, to be displayed directly after the cursor. See also + * arrowTabText. + * - emptyParameters: A promise of the text that describes the arguments that + * the user is yet to type. + * - arrowTabText: A promise of the text that *replaces* the current argument + * when TAB is pressed, generally displayed after a "|->" symbol. See also + * directTabText. + */ +Requisition.prototype.getStateData = function(start, rank) { + var typed = this.toString(); + var current = this.getAssignmentAt(start); + var context = this.executionContext; + var predictionPromise = (typed.trim().length !== 0) ? + current.getPredictionRanked(context, rank) : + Promise.resolve(null); + + return predictionPromise.then(function(prediction) { + // directTabText is for when the current input is a prefix of the completion + // arrowTabText is for when we need to use an -> to show what will be used + var directTabText = ''; + var arrowTabText = ''; + var emptyParameters = []; + + if (typed.trim().length !== 0) { + var cArg = current.arg; + + if (prediction) { + var tabText = prediction.name; + var existing = cArg.text; + + // Normally the cursor being just before whitespace means that you are + // 'in' the previous argument, which means that the prediction is based + // on that argument, however NamedArguments break this by having 2 parts + // so we need to prepend the tabText with a space for NamedArguments, + // but only when there isn't already a space at the end of the prefix + // (i.e. ' --name' not ' --name ') + if (current.isInName()) { + tabText = ' ' + tabText; + } + + if (existing !== tabText) { + // Decide to use directTabText or arrowTabText + // Strip any leading whitespace from the user inputted value because + // the tabText will never have leading whitespace. + var inputValue = existing.replace(/^\s*/, ''); + var isStrictCompletion = tabText.indexOf(inputValue) === 0; + if (isStrictCompletion && start === typed.length) { + // Display the suffix of the prediction as the completion + var numLeadingSpaces = existing.match(/^(\s*)/)[0].length; + + directTabText = tabText.slice(existing.length - numLeadingSpaces); + } + else { + // Display the '-> prediction' at the end of the completer element + // \u21E5 is the JS escape right arrow + arrowTabText = '\u21E5 ' + tabText; + } + } + } + else { + // There's no prediction, but if this is a named argument that needs a + // value (that is without any) then we need to show that one is needed + // For example 'git commit --message ', clearly needs some more text + if (cArg.type === 'NamedArgument' && cArg.valueArg == null) { + emptyParameters.push('<' + current.param.type.name + '>\u00a0'); + } + } + } + + // Add a space between the typed text (+ directTabText) and the hints, + // making sure we don't add 2 sets of padding + if (directTabText !== '') { + directTabText += '\u00a0'; // a.k.a + } + else if (!this.typedEndsWithSeparator()) { + emptyParameters.unshift('\u00a0'); + } + + // Calculate the list of parameters to be filled in + // We generate an array of emptyParameter markers for each positional + // parameter to the current command. + // Generally each emptyParameter marker begins with a space to separate it + // from whatever came before, unless what comes before ends in a space. + + this.getAssignments().forEach(function(assignment) { + // Named arguments are handled with a group [options] marker + if (!assignment.param.isPositionalAllowed) { + return; + } + + // No hints if we've got content for this parameter + if (assignment.arg.toString().trim() !== '') { + return; + } + + // No hints if we have a prediction + if (directTabText !== '' && current === assignment) { + return; + } + + var text = (assignment.param.isDataRequired) ? + '<' + assignment.param.name + '>\u00a0' : + '[' + assignment.param.name + ']\u00a0'; + + emptyParameters.push(text); + }.bind(this)); + + var command = this.commandAssignment.value; + var addOptionsMarker = false; + + // We add an '[options]' marker when there are named parameters that are + // not filled in and not hidden, and we don't have any directTabText + if (command && command.hasNamedParameters) { + command.params.forEach(function(param) { + var arg = this.getAssignment(param.name).arg; + if (!param.isPositionalAllowed && !param.hidden + && arg.type === 'BlankArgument') { + addOptionsMarker = true; + } + }, this); + } + + if (addOptionsMarker) { + // Add an nbsp if we don't have one at the end of the input or if + // this isn't the first param we've mentioned + emptyParameters.push('[options]\u00a0'); + } + + // Is the entered command a JS command with no closing '}'? + var unclosedJs = command && command.name === '{' && + this.getAssignment(0).arg.suffix.indexOf('}') === -1; + + return { + statusMarkup: this.getInputStatusMarkup(start), + unclosedJs: unclosedJs, + directTabText: directTabText, + arrowTabText: arrowTabText, + emptyParameters: emptyParameters + }; + }.bind(this)); +}; + +/** + * Pressing TAB sometimes requires that we add a space to denote that we're on + * to the 'next thing'. + * @param assignment The assignment to which to append the space + */ +Requisition.prototype._addSpace = function(assignment) { + var arg = assignment.arg.beget({ suffixSpace: true }); + if (arg !== assignment.arg) { + return this.setAssignment(assignment, arg); + } + else { + return Promise.resolve(undefined); + } +}; + +/** + * Complete the argument at <tt>cursor</tt>. + * Basically the same as: + * assignment = getAssignmentAt(cursor); + * assignment.value = assignment.conversion.predictions[0]; + * Except it's done safely, and with particular care to where we place the + * space, which is complex, and annoying if we get it wrong. + * + * WARNING: complete() can happen asynchronously. + * + * @param cursor The cursor configuration. Should have start and end properties + * which should be set to start and end of the selection. + * @param rank The index of the prediction that we should choose. + * This number is not bounded by the size of the prediction array, we take the + * modulus to get it within bounds + * @return A promise which completes (with undefined) when any outstanding + * completion tasks are done. + */ +Requisition.prototype.complete = function(cursor, rank) { + var assignment = this.getAssignmentAt(cursor.start); + + var context = this.executionContext; + var predictionPromise = assignment.getPredictionRanked(context, rank); + return predictionPromise.then(function(prediction) { + var outstanding = []; + + // Note: Since complete is asynchronous we should perhaps have a system to + // bail out of making changes if the command line has changed since TAB + // was pressed. It's not yet clear if this will be a problem. + + if (prediction == null) { + // No predictions generally means we shouldn't change anything on TAB, + // but TAB has the connotation of 'next thing' and when we're at the end + // of a thing that implies that we should add a space. i.e. + // 'help<TAB>' -> 'help ' + // But we should only do this if the thing that we're 'completing' is + // valid and doesn't already end in a space. + if (assignment.arg.suffix.slice(-1) !== ' ' && + assignment.getStatus() === Status.VALID) { + outstanding.push(this._addSpace(assignment)); + } + + // Also add a space if we are in the name part of an assignment, however + // this time we don't want the 'push the space to the next assignment' + // logic, so we don't use addSpace + if (assignment.isInName()) { + var newArg = assignment.arg.beget({ prefixPostSpace: true }); + outstanding.push(this.setAssignment(assignment, newArg)); + } + } + else { + // Mutate this argument to hold the completion + var arg = assignment.arg.beget({ + text: prediction.name, + dontQuote: (assignment === this.commandAssignment) + }); + var assignPromise = this.setAssignment(assignment, arg); + + if (!prediction.incomplete) { + assignPromise = assignPromise.then(function() { + // The prediction is complete, add a space to let the user move-on + return this._addSpace(assignment).then(function() { + // Bug 779443 - Remove or explain the re-parse + if (assignment instanceof UnassignedAssignment) { + return this.update(this.toString()); + } + }.bind(this)); + }.bind(this)); + } + + outstanding.push(assignPromise); + } + + return Promise.all(outstanding).then(function() { + return true; + }.bind(this)); + }.bind(this)); +}; + +/** + * Replace the current value with the lower value if such a concept exists. + */ +Requisition.prototype.nudge = function(assignment, by) { + var ctx = this.executionContext; + var val = assignment.param.type.nudge(assignment.value, by, ctx); + return Promise.resolve(val).then(function(replacement) { + if (replacement != null) { + var val = assignment.param.type.stringify(replacement, ctx); + return Promise.resolve(val).then(function(str) { + var arg = assignment.arg.beget({ text: str }); + return this.setAssignment(assignment, arg); + }.bind(this)); + } + }.bind(this)); +}; + +/** + * Helper to find the 'data-command' attribute, used by |update()| + */ +function getDataCommandAttribute(element) { + var command = element.getAttribute('data-command'); + if (!command) { + command = element.querySelector('*[data-command]') + .getAttribute('data-command'); + } + return command; +} + +/** + * Designed to be called from context.update(). Acts just like update() except + * that it also calls onExternalUpdate() to inform the UI of an unexpected + * change to the current command. + */ +Requisition.prototype._contextUpdate = function(typed) { + return this.update(typed).then(function(reply) { + this.onExternalUpdate({ typed: typed }); + return reply; + }.bind(this)); +}; + +/** + * Called by the UI when ever the user interacts with a command line input + * @param typed The contents of the input field OR an HTML element (or an event + * that targets an HTML element) which has a data-command attribute or a child + * with the same that contains the command to update with + */ +Requisition.prototype.update = function(typed) { + // Should be "if (typed instanceof HTMLElement)" except Gecko + if (typeof typed.querySelector === 'function') { + typed = getDataCommandAttribute(typed); + } + // Should be "if (typed instanceof Event)" except Gecko + if (typeof typed.currentTarget === 'object') { + typed = getDataCommandAttribute(typed.currentTarget); + } + + var updateId = this._beginChange(); + + this._args = exports.tokenize(typed); + var args = this._args.slice(0); // i.e. clone + + this._split(args); + + return this._assign(args).then(function() { + return this._endChangeCheckOrder(updateId); + }.bind(this)); +}; + +/** + * Similar to update('') except that it's guaranteed to execute synchronously + */ +Requisition.prototype.clear = function() { + var arg = new Argument('', '', ''); + this._args = [ arg ]; + + var conversion = commandModule.parse(this.executionContext, arg, false); + this.setAssignment(this.commandAssignment, conversion, { internal: true }); +}; + +/** + * tokenize() is a state machine. These are the states. + */ +var In = { + /** + * The last character was ' '. + * Typing a ' ' character will not change the mode + * Typing one of '"{ will change mode to SINGLE_Q, DOUBLE_Q or SCRIPT. + * Anything else goes into SIMPLE mode. + */ + WHITESPACE: 1, + + /** + * The last character was part of a parameter. + * Typing ' ' returns to WHITESPACE mode. Any other character + * (including '"{} which are otherwise special) does not change the mode. + */ + SIMPLE: 2, + + /** + * We're inside single quotes: ' + * Typing ' returns to WHITESPACE mode. Other characters do not change mode. + */ + SINGLE_Q: 3, + + /** + * We're inside double quotes: " + * Typing " returns to WHITESPACE mode. Other characters do not change mode. + */ + DOUBLE_Q: 4, + + /** + * We're inside { and } + * Typing } returns to WHITESPACE mode. Other characters do not change mode. + * SCRIPT mode is slightly different from other modes in that spaces between + * the {/} delimiters and the actual input are not considered significant. + * e.g: " x " is a 3 character string, delimited by double quotes, however + * { x } is a 1 character JavaScript surrounded by whitespace and {} + * delimiters. + * In the short term we assume that the JS routines can make sense of the + * extra whitespace, however at some stage we may need to move the space into + * the Argument prefix/suffix. + * Also we don't attempt to handle nested {}. See bug 678961 + */ + SCRIPT: 5 +}; + +/** + * Split up the input taking into account ', " and {. + * We don't consider \t or other classical whitespace characters to split + * arguments apart. For one thing these characters are hard to type, but also + * if the user has gone to the trouble of pasting a TAB character into the + * input field (or whatever it takes), they probably mean it. + */ +exports.tokenize = function(typed) { + // For blank input, place a dummy empty argument into the list + if (typed == null || typed.length === 0) { + return [ new Argument('', '', '') ]; + } + + if (isSimple(typed)) { + return [ new Argument(typed, '', '') ]; + } + + var mode = In.WHITESPACE; + + // First we swap out escaped characters that are special to the tokenizer. + // So a backslash followed by any of ['"{} ] is turned into a unicode private + // char so we can swap back later + typed = typed + .replace(/\\\\/g, '\uF000') + .replace(/\\ /g, '\uF001') + .replace(/\\'/g, '\uF002') + .replace(/\\"/g, '\uF003') + .replace(/\\{/g, '\uF004') + .replace(/\\}/g, '\uF005'); + + function unescape2(escaped) { + return escaped + .replace(/\uF000/g, '\\\\') + .replace(/\uF001/g, '\\ ') + .replace(/\uF002/g, '\\\'') + .replace(/\uF003/g, '\\\"') + .replace(/\uF004/g, '\\{') + .replace(/\uF005/g, '\\}'); + } + + var i = 0; // The index of the current character + var start = 0; // Where did this section start? + var prefix = ''; // Stuff that comes before the current argument + var args = []; // The array that we're creating + var blockDepth = 0; // For JS with nested {} + + // This is just a state machine. We're going through the string char by char + // The 'mode' is one of the 'In' states. As we go, we're adding Arguments + // to the 'args' array. + + while (true) { + var c = typed[i]; + var str; + switch (mode) { + case In.WHITESPACE: + if (c === '\'') { + prefix = typed.substring(start, i + 1); + mode = In.SINGLE_Q; + start = i + 1; + } + else if (c === '"') { + prefix = typed.substring(start, i + 1); + mode = In.DOUBLE_Q; + start = i + 1; + } + else if (c === '{') { + prefix = typed.substring(start, i + 1); + mode = In.SCRIPT; + blockDepth++; + start = i + 1; + } + else if (/ /.test(c)) { + // Still whitespace, do nothing + } + else { + prefix = typed.substring(start, i); + mode = In.SIMPLE; + start = i; + } + break; + + case In.SIMPLE: + // There is an edge case of xx'xx which we are assuming to + // be a single parameter (and same with ") + if (c === ' ') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, '')); + mode = In.WHITESPACE; + start = i; + prefix = ''; + } + break; + + case In.SINGLE_Q: + if (c === '\'') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + break; + + case In.DOUBLE_Q: + if (c === '"') { + str = unescape2(typed.substring(start, i)); + args.push(new Argument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + break; + + case In.SCRIPT: + if (c === '{') { + blockDepth++; + } + else if (c === '}') { + blockDepth--; + if (blockDepth === 0) { + str = unescape2(typed.substring(start, i)); + args.push(new ScriptArgument(str, prefix, c)); + mode = In.WHITESPACE; + start = i + 1; + prefix = ''; + } + } + break; + } + + i++; + + if (i >= typed.length) { + // There is nothing else to read - tidy up + if (mode === In.WHITESPACE) { + if (i !== start) { + // There's whitespace at the end of the typed string. Add it to the + // last argument's suffix, creating an empty argument if needed. + var extra = typed.substring(start, i); + var lastArg = args[args.length - 1]; + if (!lastArg) { + args.push(new Argument('', extra, '')); + } + else { + lastArg.suffix += extra; + } + } + } + else if (mode === In.SCRIPT) { + str = unescape2(typed.substring(start, i + 1)); + args.push(new ScriptArgument(str, prefix, '')); + } + else { + str = unescape2(typed.substring(start, i + 1)); + args.push(new Argument(str, prefix, '')); + } + break; + } + } + + return args; +}; + +/** + * If the input has no spaces, quotes, braces or escapes, + * we can take the fast track. + */ +function isSimple(typed) { + for (var i = 0; i < typed.length; i++) { + var c = typed.charAt(i); + if (c === ' ' || c === '"' || c === '\'' || + c === '{' || c === '}' || c === '\\') { + return false; + } + } + return true; +} + +/** + * Looks in the commands for a command extension that matches what has been + * typed at the command line. + */ +Requisition.prototype._split = function(args) { + // Handle the special case of the user typing { javascript(); } + // We use the hidden 'eval' command directly rather than shift()ing one of + // the parameters, and parse()ing it. + var conversion; + if (args[0].type === 'ScriptArgument') { + // Special case: if the user enters { console.log('foo'); } then we need to + // use the hidden 'eval' command + var command = this.system.commands.get(evalCmd.name); + conversion = new Conversion(command, new ScriptArgument()); + this._setAssignmentInternal(this.commandAssignment, conversion); + return; + } + + var argsUsed = 1; + + while (argsUsed <= args.length) { + var arg = (argsUsed === 1) ? + args[0] : + new MergedArgument(args, 0, argsUsed); + + if (this.prefix != null && this.prefix !== '') { + var prefixArg = new Argument(this.prefix, '', ' '); + var prefixedArg = new MergedArgument([ prefixArg, arg ]); + + conversion = commandModule.parse(this.executionContext, prefixedArg, false); + if (conversion.value == null) { + conversion = commandModule.parse(this.executionContext, arg, false); + } + } + else { + conversion = commandModule.parse(this.executionContext, arg, false); + } + + // We only want to carry on if this command is a parent command, + // which means that there is a commandAssignment, but not one with + // an exec function. + if (!conversion.value || conversion.value.exec) { + break; + } + + // Previously we needed a way to hide commands depending context. + // We have not resurrected that feature yet, but if we do we should + // insert code here to ignore certain commands depending on the + // context/environment + + argsUsed++; + } + + // This could probably be re-written to consume args as we go + for (var i = 0; i < argsUsed; i++) { + args.shift(); + } + + this._setAssignmentInternal(this.commandAssignment, conversion); +}; + +/** + * Add all the passed args to the list of unassigned assignments. + */ +Requisition.prototype._addUnassignedArgs = function(args) { + args.forEach(function(arg) { + this._unassigned.push(new UnassignedAssignment(this, arg)); + }.bind(this)); + + return RESOLVED; +}; + +/** + * Work out which arguments are applicable to which parameters. + */ +Requisition.prototype._assign = function(args) { + // See comment in _split. Avoid multiple updates + var noArgUp = { internal: true }; + + this._unassigned = []; + + if (!this.commandAssignment.value) { + return this._addUnassignedArgs(args); + } + + if (args.length === 0) { + this._setBlankArguments(); + return RESOLVED; + } + + // Create an error if the command does not take parameters, but we have + // been given them ... + if (this.assignmentCount === 0) { + return this._addUnassignedArgs(args); + } + + // Special case: if there is only 1 parameter, and that's of type + // text, then we put all the params into the first param + if (this.assignmentCount === 1) { + var assignment = this.getAssignment(0); + if (assignment.param.type.name === 'string') { + var arg = (args.length === 1) ? args[0] : new MergedArgument(args); + return this.setAssignment(assignment, arg, noArgUp); + } + } + + // Positional arguments can still be specified by name, but if they are + // then we need to ignore them when working them out positionally + var unassignedParams = this.getParameterNames(); + + // We collect the arguments used in arrays here before assigning + var arrayArgs = {}; + + // Extract all the named parameters + var assignments = this.getAssignments(false); + var namedDone = util.promiseEach(assignments, function(assignment) { + // Loop over the arguments + // Using while rather than loop because we remove args as we go + var i = 0; + while (i < args.length) { + if (!assignment.param.isKnownAs(args[i].text)) { + // Skip this parameter and handle as a positional parameter + i++; + continue; + } + + var arg = args.splice(i, 1)[0]; + /* jshint loopfunc:true */ + unassignedParams = unassignedParams.filter(function(test) { + return test !== assignment.param.name; + }); + + // boolean parameters don't have values, default to false + if (assignment.param.type.name === 'boolean') { + arg = new TrueNamedArgument(arg); + } + else { + var valueArg = null; + if (i + 1 <= args.length) { + valueArg = args.splice(i, 1)[0]; + } + arg = new NamedArgument(arg, valueArg); + } + + if (assignment.param.type.name === 'array') { + var arrayArg = arrayArgs[assignment.param.name]; + if (!arrayArg) { + arrayArg = new ArrayArgument(); + arrayArgs[assignment.param.name] = arrayArg; + } + arrayArg.addArgument(arg); + return RESOLVED; + } + else { + if (assignment.arg.type === 'BlankArgument') { + return this.setAssignment(assignment, arg, noArgUp); + } + else { + return this._addUnassignedArgs(arg.getArgs()); + } + } + } + }, this); + + // What's left are positional parameters: assign in order + var positionalDone = namedDone.then(function() { + return util.promiseEach(unassignedParams, function(name) { + var assignment = this.getAssignment(name); + + // If not set positionally, and we can't set it non-positionally, + // we have to default it to prevent previous values surviving + if (!assignment.param.isPositionalAllowed) { + this._setBlankAssignment(assignment); + return RESOLVED; + } + + // If this is a positional array argument, then it swallows the + // rest of the arguments. + if (assignment.param.type.name === 'array') { + var arrayArg = arrayArgs[assignment.param.name]; + if (!arrayArg) { + arrayArg = new ArrayArgument(); + arrayArgs[assignment.param.name] = arrayArg; + } + arrayArg.addArguments(args); + args = []; + // The actual assignment to the array parameter is done below + return RESOLVED; + } + + // Set assignment to defaults if there are no more arguments + if (args.length === 0) { + this._setBlankAssignment(assignment); + return RESOLVED; + } + + var arg = args.splice(0, 1)[0]; + // --foo and -f are named parameters, -4 is a number. So '-' is either + // the start of a named parameter or a number depending on the context + var isIncompleteName = assignment.param.type.name === 'number' ? + /-[-a-zA-Z_]/.test(arg.text) : + arg.text.charAt(0) === '-'; + + if (isIncompleteName) { + this._unassigned.push(new UnassignedAssignment(this, arg)); + return RESOLVED; + } + else { + return this.setAssignment(assignment, arg, noArgUp); + } + }, this); + }.bind(this)); + + // Now we need to assign the array argument (if any) + var arrayDone = positionalDone.then(function() { + return util.promiseEach(Object.keys(arrayArgs), function(name) { + var assignment = this.getAssignment(name); + return this.setAssignment(assignment, arrayArgs[name], noArgUp); + }, this); + }.bind(this)); + + // What's left is can't be assigned, but we need to officially unassign them + return arrayDone.then(function() { + return this._addUnassignedArgs(args); + }.bind(this)); +}; + +/** + * Entry point for keyboard accelerators or anything else that wants to execute + * a command. + * @param options Object describing how the execution should be handled. + * (optional). Contains some of the following properties: + * - hidden (boolean, default=false) Should the output be hidden from the + * commandOutputManager for this requisition + * - command/args A fast shortcut to executing a known command with a known + * set of parsed arguments. + */ +Requisition.prototype.exec = function(options) { + var command = null; + var args = null; + var hidden = false; + + if (options) { + if (options.hidden) { + hidden = true; + } + + if (options.command != null) { + // Fast track by looking up the command directly since passed args + // means there is no command line to parse. + command = this.system.commands.get(options.command); + if (!command) { + console.error('Command not found: ' + options.command); + } + args = options.args; + } + } + + if (!command) { + command = this.commandAssignment.value; + args = this.getArgsObject(); + } + + // Display JavaScript input without the initial { or closing } + var typed = this.toString(); + if (evalCmd.isCommandRegexp.test(typed)) { + typed = typed.replace(evalCmd.isCommandRegexp, ''); + // Bug 717763: What if the JavaScript naturally ends with a }? + typed = typed.replace(/\s*}\s*$/, ''); + } + + var output = new Output({ + command: command, + args: args, + typed: typed, + canonical: this.toCanonicalString(), + hidden: hidden + }); + + this.commandOutputManager.onOutput({ output: output }); + + var onDone = function(data) { + output.complete(data, false); + return output; + }; + + var onError = function(data, ex) { + if (logErrors) { + if (ex != null) { + util.errorHandler(ex); + } + else { + console.error(data); + } + } + + if (data != null && typeof data === 'string') { + data = data.replace(/^Protocol error: /, ''); // Temp fix for bug 1035296 + } + + data = (data != null && data.isTypedData) ? data : { + isTypedData: true, + data: data, + type: 'error' + }; + output.complete(data, true); + return output; + }; + + if (this.status !== Status.VALID) { + var ex = new Error(this.getStatusMessage()); + // We only reject a call to exec if GCLI breaks. Errors with commands are + // exposed in the 'error' status of the Output object + return Promise.resolve(onError(ex)).then(function(output) { + this.clear(); + return output; + }.bind(this)); + } + else { + try { + return host.exec(function() { + return command.exec(args, this.executionContext); + }.bind(this)).then(onDone, onError); + } + catch (ex) { + var data = (typeof ex.message === 'string' && ex.stack != null) ? + ex.message : ex; + return Promise.resolve(onError(data, ex)); + } + finally { + this.clear(); + } + } +}; + +/** + * Designed to be called from context.updateExec(). Acts just like updateExec() + * except that it also calls onExternalUpdate() to inform the UI of an + * unexpected change to the current command. + */ +Requisition.prototype._contextUpdateExec = function(typed, options) { + var reqOpts = { + document: this.document, + environment: this.environment + }; + var child = new Requisition(this.system, reqOpts); + return child.updateExec(typed, options).then(function(reply) { + child.destroy(); + return reply; + }.bind(child)); +}; + +/** + * A shortcut for calling update, resolving the promise and then exec. + * @param input The string to execute + * @param options Passed to exec + * @return A promise of an output object + */ +Requisition.prototype.updateExec = function(input, options) { + return this.update(input).then(function() { + return this.exec(options); + }.bind(this)); +}; + +exports.Requisition = Requisition; + +/** + * A simple object to hold information about the output of a command + */ +function Output(options) { + options = options || {}; + this.command = options.command || ''; + this.args = options.args || {}; + this.typed = options.typed || ''; + this.canonical = options.canonical || ''; + this.hidden = options.hidden === true ? true : false; + + this.type = undefined; + this.data = undefined; + this.completed = false; + this.error = false; + this.start = new Date(); + + this.promise = new Promise(function(resolve, reject) { + this._resolve = resolve; + }.bind(this)); +} + +/** + * Called when there is data to display, and the command has finished executing + * See changed() for details on parameters. + */ +Output.prototype.complete = function(data, error) { + this.end = new Date(); + this.completed = true; + this.error = error; + + if (data != null && data.isTypedData) { + this.data = data.data; + this.type = data.type; + } + else { + this.data = data; + this.type = this.command.returnType; + if (this.type == null) { + this.type = (this.data == null) ? 'undefined' : typeof this.data; + } + } + + if (this.type === 'object') { + throw new Error('No type from output of ' + this.typed); + } + + this._resolve(); +}; + +/** + * Call converters.convert using the data in this Output object + */ +Output.prototype.convert = function(type, conversionContext) { + var converters = conversionContext.system.converters; + return converters.convert(this.data, this.type, type, conversionContext); +}; + +Output.prototype.toJson = function() { + // Exceptions don't stringify, so we try a bit harder + var data = this.data; + if (this.error && JSON.stringify(this.data) === '{}') { + data = { + columnNumber: data.columnNumber, + fileName: data.fileName, + lineNumber: data.lineNumber, + message: data.message, + stack: data.stack + }; + } + + return { + typed: this.typed, + type: this.type, + data: data, + isError: this.error + }; +}; + +exports.Output = Output; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/clear.js b/devtools/shared/gcli/source/lib/gcli/commands/clear.js new file mode 100644 index 000000000..8f9327021 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/clear.js @@ -0,0 +1,59 @@ +/* + * 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 l10n = require('../util/l10n'); + +exports.items = [ + { + // A command to clear the output area + item: 'command', + runAt: 'client', + name: 'clear', + description: l10n.lookup('clearDesc'), + returnType: 'clearoutput', + exec: function(args, context) { } + }, + { + item: 'converter', + from: 'clearoutput', + to: 'view', + exec: function(ignore, conversionContext) { + return { + html: '<span onload="${onload}"></span>', + data: { + onload: function(ev) { + // element starts off being the span above, and we walk up the + // tree looking for the terminal + var element = ev.target; + while (element != null && element.terminal == null) { + element = element.parentElement; + } + + if (element == null) { + // This is only an event handler on a completed command + // So we're relying on this showing up in the console + throw new Error('Failed to find clear'); + } + + element.terminal.clear(); + } + } + }; + } + } +]; 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; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/context.js b/devtools/shared/gcli/source/lib/gcli/commands/context.js new file mode 100644 index 000000000..ad1f87ee8 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/context.js @@ -0,0 +1,62 @@ +/* + * 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 l10n = require('../util/l10n'); +var cli = require('../cli'); + +/** + * 'context' command + */ +var context = { + item: 'command', + name: 'context', + description: l10n.lookup('contextDesc'), + manual: l10n.lookup('contextManual'), + params: [ + { + name: 'prefix', + type: 'command', + description: l10n.lookup('contextPrefixDesc'), + defaultValue: null + } + ], + returnType: 'string', + // The context command is client only because it's essentially sugar for + // typing commands. When there is a command prefix in action, it is the job + // of the remoter to add the prefix to the typed strings that are sent for + // remote execution + noRemote: true, + exec: function echo(args, context) { + var requisition = cli.getMapping(context).requisition; + + if (args.prefix == null) { + requisition.prefix = null; + return l10n.lookup('contextEmptyReply'); + } + + if (args.prefix.exec != null) { + throw new Error(l10n.lookupFormat('contextNotParentError', + [ args.prefix.name ])); + } + + requisition.prefix = args.prefix.name; + return l10n.lookupFormat('contextReply', [ args.prefix.name ]); + } +}; + +exports.items = [ context ]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/help.js b/devtools/shared/gcli/source/lib/gcli/commands/help.js new file mode 100644 index 000000000..317f80240 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/help.js @@ -0,0 +1,387 @@ +/* + * 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 l10n = require('../util/l10n'); +var cli = require('../cli'); + +/** + * Add an 'paramGroups' accessor to a command metadata object to sort the + * params into groups according to the option of the param. + */ +function addParamGroups(command) { + Object.defineProperty(command, 'paramGroups', { + get: function() { + var paramGroups = {}; + this.params.forEach(function(param) { + var groupName = param.option || l10n.lookup('canonDefaultGroupName'); + if (paramGroups[groupName] == null) { + paramGroups[groupName] = []; + } + paramGroups[groupName].push(param); + }); + return paramGroups; + }, + enumerable: true + }); +} + +/** + * Get a data block for the help_man.html/help_man.txt templates + */ +function getHelpManData(commandData, context) { + // Filter out hidden parameters + commandData.command.params = commandData.command.params.filter( + param => !param.hidden + ); + + addParamGroups(commandData.command); + commandData.subcommands.forEach(addParamGroups); + + return { + l10n: l10n.propertyLookup, + onclick: context.update, + ondblclick: context.updateExec, + describe: function(item) { + return item.manual || item.description; + }, + getTypeDescription: function(param) { + var input = ''; + if (param.defaultValue === undefined) { + input = l10n.lookup('helpManRequired'); + } + else if (param.defaultValue === null) { + input = l10n.lookup('helpManOptional'); + } + else { + // We need defaultText to work the text version of defaultValue + input = l10n.lookupFormat('helpManOptional'); + /* + var val = param.type.stringify(param.defaultValue); + input = Promise.resolve(val).then(function(defaultValue) { + return l10n.lookupFormat('helpManDefault', [ defaultValue ]); + }.bind(this)); + */ + } + + return Promise.resolve(input).then(function(defaultDescr) { + return '(' + (param.type.name || param.type) + ', ' + defaultDescr + ')'; + }.bind(this)); + }, + getSynopsis: function(param) { + var name = param.name + (param.short ? '|-' + param.short : ''); + if (param.option == null) { + return param.defaultValue !== undefined ? + '[' + name + ']' : + '<' + name + '>'; + } + else { + return param.type === 'boolean' || param.type.name === 'boolean' ? + '[--' + name + ']' : + '[--' + name + ' ...]'; + } + }, + command: commandData.command, + subcommands: commandData.subcommands + }; +} + +/** + * Get a data block for the help_list.html/help_list.txt templates + */ +function getHelpListData(commandsData, context) { + commandsData.commands.forEach(addParamGroups); + + var heading; + if (commandsData.commands.length === 0) { + heading = l10n.lookupFormat('helpListNone', [ commandsData.prefix ]); + } + else if (commandsData.prefix == null) { + heading = l10n.lookup('helpListAll'); + } + else { + heading = l10n.lookupFormat('helpListPrefix', [ commandsData.prefix ]); + } + + return { + l10n: l10n.propertyLookup, + includeIntro: commandsData.prefix == null, + heading: heading, + onclick: context.update, + ondblclick: context.updateExec, + matchingCommands: commandsData.commands + }; +} + +/** + * Create a block of data suitable to be passed to the help_list.html template + */ +function getMatchingCommands(context, prefix) { + var commands = cli.getMapping(context).requisition.system.commands; + var reply = commands.getAll().filter(function(command) { + if (command.hidden) { + return false; + } + + if (prefix && command.name.indexOf(prefix) !== 0) { + // Filtered out because they don't match the search + return false; + } + if (!prefix && command.name.indexOf(' ') != -1) { + // We don't show sub commands with plain 'help' + return false; + } + return true; + }); + + reply.sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + + reply = reply.map(function(command) { + return command.toJson(); + }); + + return reply; +} + +/** + * Find all the sub commands of the given command + */ +function getSubCommands(context, command) { + var commands = cli.getMapping(context).requisition.system.commands; + var subcommands = commands.getAll().filter(function(subcommand) { + return subcommand.name.indexOf(command.name) === 0 && + subcommand.name !== command.name && + !subcommand.hidden; + }); + + subcommands.sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + + subcommands = subcommands.map(function(subcommand) { + return subcommand.toJson(); + }); + + return subcommands; +} + +var helpCss = '' + + '.gcli-help-name {\n' + + ' text-align: end;\n' + + '}\n' + + '\n' + + '.gcli-help-arrow {\n' + + ' color: #AAA;\n' + + '}\n' + + '\n' + + '.gcli-help-description {\n' + + ' margin: 0 20px;\n' + + ' padding: 0;\n' + + '}\n' + + '\n' + + '.gcli-help-parameter {\n' + + ' margin: 0 30px;\n' + + ' padding: 0;\n' + + '}\n' + + '\n' + + '.gcli-help-header {\n' + + ' margin: 10px 0 6px;\n' + + '}\n'; + +exports.items = [ + { + // 'help' command + item: 'command', + name: 'help', + runAt: 'client', + description: l10n.lookup('helpDesc'), + manual: l10n.lookup('helpManual'), + params: [ + { + name: 'search', + type: 'string', + description: l10n.lookup('helpSearchDesc'), + manual: l10n.lookup('helpSearchManual3'), + defaultValue: null + } + ], + + exec: function(args, context) { + var commands = cli.getMapping(context).requisition.system.commands; + var command = commands.get(args.search); + if (command) { + return context.typedData('commandData', { + command: command.toJson(), + subcommands: getSubCommands(context, command) + }); + } + + return context.typedData('commandsData', { + prefix: args.search, + commands: getMatchingCommands(context, args.search) + }); + } + }, + { + // Convert a command into an HTML man page + item: 'converter', + from: 'commandData', + to: 'view', + exec: function(commandData, context) { + return { + html: + '<div>\n' + + ' <p class="gcli-help-header">\n' + + ' ${l10n.helpManSynopsis}:\n' + + ' <span class="gcli-out-shortcut" data-command="${command.name}"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}">\n' + + ' ${command.name}\n' + + ' <span foreach="param in ${command.params}">${getSynopsis(param)} </span>\n' + + ' </span>\n' + + ' </p>\n' + + '\n' + + ' <p class="gcli-help-description">${describe(command)}</p>\n' + + '\n' + + ' <div if="${!command.isParent}">\n' + + ' <div foreach="groupName in ${command.paramGroups}">\n' + + ' <p class="gcli-help-header">${groupName}:</p>\n' + + ' <ul class="gcli-help-parameter">\n' + + ' <li if="${command.params.length === 0}">${l10n.helpManNone}</li>\n' + + ' <li foreach="param in ${command.paramGroups[groupName]}">\n' + + ' <code>${getSynopsis(param)}</code> <em>${getTypeDescription(param)}</em>\n' + + ' <br/>\n' + + ' ${describe(param)}\n' + + ' </li>\n' + + ' </ul>\n' + + ' </div>\n' + + ' </div>\n' + + '\n' + + ' <div if="${command.isParent}">\n' + + ' <p class="gcli-help-header">${l10n.subCommands}:</p>\n' + + ' <ul class="gcli-help-${subcommands}">\n' + + ' <li if="${subcommands.length === 0}">${l10n.subcommandsNone}</li>\n' + + ' <li foreach="subcommand in ${subcommands}">\n' + + ' ${subcommand.name}: ${subcommand.description}\n' + + ' <span class="gcli-out-shortcut" data-command="help ${subcommand.name}"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}">\n' + + ' help ${subcommand.name}\n' + + ' </span>\n' + + ' </li>\n' + + ' </ul>\n' + + ' </div>\n' + + '\n' + + '</div>\n', + options: { allowEval: true, stack: 'commandData->view' }, + data: getHelpManData(commandData, context), + css: helpCss, + cssId: 'gcli-help' + }; + } + }, + { + // Convert a command into a string based man page + item: 'converter', + from: 'commandData', + to: 'stringView', + exec: function(commandData, context) { + return { + html: + '<div>## ${command.name}\n' + + '\n' + + '# ${l10n.helpManSynopsis}: ${command.name} <loop foreach="param in ${command.params}">${getSynopsis(param)} </loop>\n' + + '\n' + + '# ${l10n.helpManDescription}:\n' + + '\n' + + '${command.manual || command.description}\n' + + '\n' + + '<loop foreach="groupName in ${command.paramGroups}">\n' + + '<span if="${!command.isParent}"># ${groupName}:\n' + + '\n' + + '<span if="${command.params.length === 0}">${l10n.helpManNone}</span><loop foreach="param in ${command.paramGroups[groupName]}">* ${param.name}: ${getTypeDescription(param)}\n' + + ' ${param.manual || param.description}\n' + + '</loop>\n' + + '</span>\n' + + '</loop>\n' + + '\n' + + '<span if="${command.isParent}"># ${l10n.subCommands}:</span>\n' + + '\n' + + '<span if="${subcommands.length === 0}">${l10n.subcommandsNone}</span>\n' + + '<loop foreach="subcommand in ${subcommands}">* ${subcommand.name}: ${subcommand.description}\n' + + '</loop>\n' + + '</div>\n', + options: { allowEval: true, stack: 'commandData->stringView' }, + data: getHelpManData(commandData, context) + }; + } + }, + { + // Convert a list of commands into a formatted list + item: 'converter', + from: 'commandsData', + to: 'view', + exec: function(commandsData, context) { + return { + html: + '<div>\n' + + ' <div if="${includeIntro}">\n' + + ' <p>${l10n.helpIntro}</p>\n' + + ' </div>\n' + + '\n' + + ' <p>${heading}</p>\n' + + '\n' + + ' <table>\n' + + ' <tr foreach="command in ${matchingCommands}">\n' + + ' <td class="gcli-help-name">${command.name}</td>\n' + + ' <td class="gcli-help-arrow">-</td>\n' + + ' <td>\n' + + ' ${command.description}\n' + + ' <span class="gcli-out-shortcut"\n' + + ' onclick="${onclick}" ondblclick="${ondblclick}"\n' + + ' data-command="help ${command.name}">help ${command.name}</span>\n' + + ' </td>\n' + + ' </tr>\n' + + ' </table>\n' + + '</div>\n', + options: { allowEval: true, stack: 'commandsData->view' }, + data: getHelpListData(commandsData, context), + css: helpCss, + cssId: 'gcli-help' + }; + } + }, + { + // Convert a list of commands into a formatted list + item: 'converter', + from: 'commandsData', + to: 'stringView', + exec: function(commandsData, context) { + return { + html: + '<pre><span if="${includeIntro}">## ${l10n.helpIntro}</span>\n' + + '\n' + + '# ${heading}\n' + + '\n' + + '<loop foreach="command in ${matchingCommands}">${command.name} → ${command.description}\n' + + '</loop></pre>', + options: { allowEval: true, stack: 'commandsData->stringView' }, + data: getHelpListData(commandsData, context) + }; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/mocks.js b/devtools/shared/gcli/source/lib/gcli/commands/mocks.js new file mode 100644 index 000000000..12b2ade86 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/mocks.js @@ -0,0 +1,68 @@ +/* + * 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 cli = require('../cli'); +var mockCommands = require('../test/mockCommands'); +var mockFileCommands = require('../test/mockFileCommands'); +var mockSettings = require('../test/mockSettings'); + +var isNode = (typeof(process) !== 'undefined' && + process.title.indexOf('node') != -1); + +exports.items = [ + { + item: 'command', + name: 'mocks', + description: 'Add/remove mock commands', + params: [ + { + name: 'included', + type: { + name: 'selection', + data: [ 'on', 'off' ] + }, + description: 'Turn mock commands on or off', + } + ], + returnType: 'string', + + exec: function(args, context) { + var requisition = cli.getMapping(context).requisition; + this[args.included](requisition); + return 'Mock commands are now ' + args.included; + }, + + on: function(requisition) { + mockCommands.setup(requisition); + mockSettings.setup(requisition.system); + + if (isNode) { + mockFileCommands.setup(requisition); + } + }, + + off: function(requisition) { + mockCommands.shutdown(requisition); + mockSettings.shutdown(requisition.system); + + if (isNode) { + mockFileCommands.shutdown(requisition); + } + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/moz.build b/devtools/shared/gcli/source/lib/gcli/commands/moz.build new file mode 100644 index 000000000..8cf5f0e96 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'clear.js', + 'commands.js', + 'context.js', + 'help.js', + 'mocks.js', + 'pref.js', + 'preflist.js', + 'test.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/commands/pref.js b/devtools/shared/gcli/source/lib/gcli/commands/pref.js new file mode 100644 index 000000000..387b1f8e4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/pref.js @@ -0,0 +1,93 @@ +/* + * 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 l10n = require('../util/l10n'); + +exports.items = [ + { + // 'pref' command + item: 'command', + name: 'pref', + description: l10n.lookup('prefDesc'), + manual: l10n.lookup('prefManual') + }, + { + // 'pref show' command + item: 'command', + name: 'pref show', + runAt: 'client', + description: l10n.lookup('prefShowDesc'), + manual: l10n.lookup('prefShowManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefShowSettingDesc'), + manual: l10n.lookup('prefShowSettingManual') + } + ], + exec: function(args, context) { + return l10n.lookupFormat('prefShowSettingValue', + [ args.setting.name, args.setting.value ]); + } + }, + { + // 'pref set' command + item: 'command', + name: 'pref set', + runAt: 'client', + description: l10n.lookup('prefSetDesc'), + manual: l10n.lookup('prefSetManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefSetSettingDesc'), + manual: l10n.lookup('prefSetSettingManual') + }, + { + name: 'value', + type: 'settingValue', + description: l10n.lookup('prefSetValueDesc'), + manual: l10n.lookup('prefSetValueManual') + } + ], + exec: function(args, context) { + args.setting.value = args.value; + } + }, + { + // 'pref reset' command + item: 'command', + name: 'pref reset', + runAt: 'client', + description: l10n.lookup('prefResetDesc'), + manual: l10n.lookup('prefResetManual'), + params: [ + { + name: 'setting', + type: 'setting', + description: l10n.lookup('prefResetSettingDesc'), + manual: l10n.lookup('prefResetSettingManual') + } + ], + exec: function(args, context) { + args.setting.setDefault(); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/preflist.js b/devtools/shared/gcli/source/lib/gcli/commands/preflist.js new file mode 100644 index 000000000..b6ca04a0b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/preflist.js @@ -0,0 +1,214 @@ +/* + * 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 l10n = require('../util/l10n'); + +/** + * Format a list of settings for display + */ +var prefsViewConverter = { + item: 'converter', + from: 'prefsData', + to: 'view', + exec: function(prefsData, conversionContext) { + var prefList = new PrefList(prefsData, conversionContext); + return { + html: + '<div ignore="${onLoad(__element)}">\n' + + ' <!-- This is broken, and unimportant. Comment out for now\n' + + ' <div class="gcli-pref-list-filter">\n' + + ' ${l10n.prefOutputFilter}:\n' + + ' <input onKeyUp="${onFilterChange}" value="${search}"/>\n' + + ' </div>\n' + + ' -->\n' + + ' <table class="gcli-pref-list-table">\n' + + ' <colgroup>\n' + + ' <col class="gcli-pref-list-name"/>\n' + + ' <col class="gcli-pref-list-value"/>\n' + + ' </colgroup>\n' + + ' <tr>\n' + + ' <th>${l10n.prefOutputName}</th>\n' + + ' <th>${l10n.prefOutputValue}</th>\n' + + ' </tr>\n' + + ' </table>\n' + + ' <div class="gcli-pref-list-scroller">\n' + + ' <table class="gcli-pref-list-table" save="${table}">\n' + + ' </table>\n' + + ' </div>\n' + + '</div>\n', + data: prefList, + options: { + blankNullUndefined: true, + allowEval: true, + stack: 'prefsData->view' + }, + css: + '.gcli-pref-list-scroller {\n' + + ' max-height: 200px;\n' + + ' overflow-y: auto;\n' + + ' overflow-x: hidden;\n' + + ' display: inline-block;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table {\n' + + ' width: 500px;\n' + + ' table-layout: fixed;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table tr > th {\n' + + ' text-align: left;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-table tr > td {\n' + + ' text-overflow: elipsis;\n' + + ' word-wrap: break-word;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-name {\n' + + ' width: 70%;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-command {\n' + + ' display: none;\n' + + '}\n' + + '\n' + + '.gcli-pref-list-row:hover .gcli-pref-list-command {\n' + + ' /* \'pref list\' is a bit broken and unimportant. Band-aid follows */\n' + + ' /* display: inline-block; */\n' + + '}\n', + cssId: 'gcli-pref-list' + }; + } +}; + +/** + * Format a list of settings for display + */ +var prefsStringConverter = { + item: 'converter', + from: 'prefsData', + to: 'string', + exec: function(prefsData, conversionContext) { + var reply = ''; + prefsData.settings.forEach(function(setting) { + reply += setting.name + ' -> ' + setting.value + '\n'; + }); + return reply; + } +}; + +/** + * 'pref list' command + */ +var prefList = { + item: 'command', + name: 'pref list', + description: l10n.lookup('prefListDesc'), + manual: l10n.lookup('prefListManual'), + params: [ + { + name: 'search', + type: 'string', + defaultValue: null, + description: l10n.lookup('prefListSearchDesc'), + manual: l10n.lookup('prefListSearchManual') + } + ], + returnType: 'prefsData', + exec: function(args, context) { + return new Promise(function(resolve, reject) { + // This can be slow, get out of the way of the main thread + setTimeout(function() { + var prefsData = { + settings: context.system.settings.getAll(args.search), + search: args.search + }; + resolve(prefsData); + }.bind(this), 10); + }); + } +}; + +/** + * A manager for our version of about:config + */ +function PrefList(prefsData, conversionContext) { + this.search = prefsData.search; + this.settings = prefsData.settings; + this.conversionContext = conversionContext; + + this.onLoad = this.onLoad.bind(this); +} + +/** + * A load event handler registered by the template engine so we can load the + * inner document + */ +PrefList.prototype.onLoad = function(element) { + var table = element.querySelector('.gcli-pref-list-table'); + this.updateTable(table); + return ''; +}; + +/** + * Forward localization lookups + */ +PrefList.prototype.l10n = l10n.propertyLookup; + +/** + * Called from the template onkeyup for the filter element + */ +PrefList.prototype.updateTable = function(table) { + var view = this.conversionContext.createView({ + html: + '<table>\n' + + ' <colgroup>\n' + + ' <col class="gcli-pref-list-name"/>\n' + + ' <col class="gcli-pref-list-value"/>\n' + + ' </colgroup>\n' + + ' <tr class="gcli-pref-list-row" foreach="setting in ${settings}">\n' + + ' <td>${setting.name}</td>\n' + + ' <td onclick="${onSetClick}" data-command="pref set ${setting.name} ">\n' + + ' ${setting.value}\n' + + ' [Edit]\n' + + ' </td>\n' + + ' </tr>\n' + + '</table>\n', + options: { blankNullUndefined: true, stack: 'prefsData#inner' }, + data: this + }); + + view.appendTo(table, true); +}; + +PrefList.prototype.onFilterChange = function(ev) { + if (ev.target.value !== this.search) { + this.search = ev.target.value; + + var root = ev.target.parentNode.parentNode; + var table = root.querySelector('.gcli-pref-list-table'); + this.updateTable(table); + } +}; + +PrefList.prototype.onSetClick = function(ev) { + var typed = ev.currentTarget.getAttribute('data-command'); + this.conversionContext.update(typed); +}; + +exports.items = [ prefsViewConverter, prefsStringConverter, prefList ]; diff --git a/devtools/shared/gcli/source/lib/gcli/commands/test.js b/devtools/shared/gcli/source/lib/gcli/commands/test.js new file mode 100644 index 000000000..90f56c361 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/commands/test.js @@ -0,0 +1,215 @@ +/* + * 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 examiner = require('../testharness/examiner'); +var stati = require('../testharness/status').stati; +var helpers = require('../test/helpers'); +var suite = require('../test/suite'); +var cli = require('../cli'); +var Requisition = require('../cli').Requisition; +var createRequisitionAutomator = require('../test/automators/requisition').createRequisitionAutomator; + +var isNode = (typeof(process) !== 'undefined' && + process.title.indexOf('node') != -1); + +suite.init(isNode); + +exports.optionsContainer = []; + +exports.items = [ + { + item: 'type', + name: 'suite', + parent: 'selection', + cacheable: true, + lookup: function() { + return Object.keys(examiner.suites).map(function(name) { + return { name: name, value: examiner.suites[name] }; + }); + } + }, + { + item: 'command', + name: 'test', + description: 'Run GCLI unit tests', + params: [ + { + name: 'suite', + type: 'suite', + description: 'Test suite to run.', + defaultValue: examiner + }, + { + name: 'usehost', + type: 'boolean', + description: 'Run the unit tests in the host window', + option: true + } + ], + returnType: 'examiner-output', + noRemote: true, + exec: function(args, context) { + if (args.usehost && exports.optionsContainer.length === 0) { + throw new Error('Can\'t use --usehost without injected options'); + } + + var options; + if (args.usehost) { + options = exports.optionsContainer[0]; + } + else { + var env = { + document: document, + window: window + }; + options = { + isNode: isNode, + isFirefox: false, + isPhantomjs: false, + requisition: new Requisition(context.system, { environment: env }) + }; + options.automator = createRequisitionAutomator(options.requisition); + } + + var requisition = options.requisition; + requisition.system.commands.get('mocks').on(requisition); + helpers.resetResponseTimes(); + examiner.reset(); + + return args.suite.run(options).then(function() { + requisition.system.commands.get('mocks').off(requisition); + var output = context.typedData('examiner-output', examiner.toRemote()); + + if (output.data.summary.status === stati.pass) { + return output; + } + else { + cli.logErrors = false; + throw output; + } + }); + } + }, + { + item: 'converter', + from: 'examiner-output', + to: 'string', + exec: function(output, conversionContext) { + return '\n' + examiner.detailedResultLog('NodeJS/NoDom') + + '\n' + helpers.timingSummary; + } + }, + { + item: 'converter', + from: 'examiner-output', + to: 'view', + exec: function(output, conversionContext) { + return { + html: + '<div>\n' + + ' <table class="gcliTestResults">\n' + + ' <thead>\n' + + ' <tr>\n' + + ' <th class="gcliTestSuite">Suite</th>\n' + + ' <th>Test</th>\n' + + ' <th>Results</th>\n' + + ' <th>Checks</th>\n' + + ' <th>Notes</th>\n' + + ' </tr>\n' + + ' </thead>\n' + + ' <tbody foreach="suite in ${suites}">\n' + + ' <tr foreach="test in ${suite.tests}" title="${suite.name}.${test.name}()">\n' + + ' <td class="gcliTestSuite">${suite.name}</td>\n' + + ' <td class="gcliTestTitle">${test.title}</td>\n' + + ' <td class="gcliTest${test.status.name}">${test.status.name}</td>\n' + + ' <td class="gcliTestChecks">${test.checks}</td>\n' + + ' <td class="gcliTestMessages">\n' + + ' <div foreach="failure in ${test.failures}">\n' + + ' ${failure.message}\n' + + ' <ul if="${failure.params}">\n' + + ' <li>P1: ${failure.p1}</li>\n' + + ' <li>P2: ${failure.p2}</li>\n' + + ' </ul>\n' + + ' </div>\n' + + ' </td>\n' + + ' </tr>\n' + + ' </tbody>\n' + + ' <tfoot>\n' + + ' <tr>\n' + + ' <th></th>\n' + + ' <th>Total</th>\n' + + ' <th>${summary.status.name}</th>\n' + + ' <th class="gcliTestChecks">${summary.checks}</th>\n' + + ' <th></th>\n' + + ' </tr>\n' + + ' </tfoot>\n' + + ' </table>\n' + + '</div>', + css: + '.gcliTestSkipped {\n' + + ' background-color: #EEE;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestExecuting {\n' + + ' background-color: #888;\n' + + ' color: #FFF;\n' + + '}\n' + + '\n' + + '.gcliTestWaiting {\n' + + ' background-color: #FFA;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestPass {\n' + + ' background-color: #8F8;\n' + + ' color: #000;\n' + + '}\n' + + '\n' + + '.gcliTestFail {\n' + + ' background-color: #F00;\n' + + ' color: #FFF;\n' + + '}\n' + + '\n' + + 'td.gcliTestSuite {\n' + + ' font-family: monospace;\n' + + ' font-size: 90%;\n' + + ' text-align: right;\n' + + '}\n' + + '\n' + + '.gcliTestResults th.gcliTestSuite,\n' + + '.gcliTestResults .gcliTestChecks {\n' + + ' text-align: right;\n' + + '}\n' + + '\n' + + '.gcliTestResults th {\n' + + ' text-align: left;\n' + + '}\n' + + '\n' + + '.gcliTestMessages ul {\n' + + ' margin: 0 0 10px;\n' + + ' padding-left: 20px;\n' + + ' list-style-type: square;\n' + + '}\n', + cssId: 'gcli-test', + data: output, + options: { allowEval: true, stack: 'test.html' } + }; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js b/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js new file mode 100644 index 000000000..f1a6fe339 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/connectors/connectors.js @@ -0,0 +1,157 @@ +/* + * 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'; + +/** + * This is how to implement a connector + * var baseConnector = { + * item: 'connector', + * name: 'foo', + * + * connect: function(url) { + * return Promise.resolve(new FooConnection(url)); + * } + * }; + */ + +/** + * A prototype base for Connectors + */ +function Connection() { +} + +/** + * Add an event listener + */ +Connection.prototype.on = function(event, action) { + if (!this._listeners) { + this._listeners = {}; + } + if (!this._listeners[event]) { + this._listeners[event] = []; + } + this._listeners[event].push(action); +}; + +/** + * Remove an event listener + */ +Connection.prototype.off = function(event, action) { + if (!this._listeners) { + return; + } + var actions = this._listeners[event]; + if (actions) { + this._listeners[event] = actions.filter(function(li) { + return li !== action; + }.bind(this)); + } +}; + +/** + * Emit an event. For internal use only + */ +Connection.prototype._emit = function(event, data) { + if (this._listeners == null || this._listeners[event] == null) { + return; + } + + var listeners = this._listeners[event]; + listeners.forEach(function(listener) { + // Fail fast if we mutate the list of listeners while emitting + if (listeners !== this._listeners[event]) { + throw new Error('Listener list changed while emitting'); + } + + try { + listener.call(null, data); + } + catch (ex) { + console.log('Error calling listeners to ' + event); + console.error(ex); + } + }.bind(this)); +}; + +/** + * Send a message to the other side of the connection + */ +Connection.prototype.call = function(feature, data) { + throw new Error('Not implemented'); +}; + +/** + * Disconnecting a Connection destroys the resources it holds. There is no + * common route back to being connected once this has been called + */ +Connection.prototype.disconnect = function() { + return Promise.resolve(); +}; + +exports.Connection = Connection; + +/** + * A manager for the registered Connectors + */ +function Connectors() { + // This is where we cache the connectors that we know about + this._registered = {}; +} + +/** + * Add a new connector to the cache + */ +Connectors.prototype.add = function(connector) { + this._registered[connector.name] = connector; +}; + +/** + * Remove an existing connector from the cache + */ +Connectors.prototype.remove = function(connector) { + var name = typeof connector === 'string' ? connector : connector.name; + delete this._registered[name]; +}; + +/** + * Get access to the list of known connectors + */ +Connectors.prototype.getAll = function() { + return Object.keys(this._registered).map(function(name) { + return this._registered[name]; + }.bind(this)); +}; + +var defaultConnectorName; + +/** + * Get access to a connector by name. If name is undefined then first try to + * use the same connector that we used last time, and if there was no last + * time, then just use the first registered connector as a default. + */ +Connectors.prototype.get = function(name) { + if (name == null) { + name = (defaultConnectorName == null) ? + Object.keys(this._registered)[0] : + defaultConnectorName; + } + + defaultConnectorName = name; + return this._registered[name]; +}; + +exports.Connectors = Connectors; diff --git a/devtools/shared/gcli/source/lib/gcli/connectors/moz.build b/devtools/shared/gcli/source/lib/gcli/connectors/moz.build new file mode 100644 index 000000000..33fda8fbc --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/connectors/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'connectors.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/converters/basic.js b/devtools/shared/gcli/source/lib/gcli/converters/basic.js new file mode 100644 index 000000000..3cb448e91 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/basic.js @@ -0,0 +1,94 @@ +/* + * 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'); + +/** + * Several converters are just data.toString inside a 'p' element + */ +function nodeFromDataToString(data, conversionContext) { + var node = util.createElement(conversionContext.document, 'p'); + node.textContent = data.toString(); + return node; +} + +exports.items = [ + { + item: 'converter', + from: 'string', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'number', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'boolean', + to: 'dom', + exec: nodeFromDataToString + }, + { + item: 'converter', + from: 'undefined', + to: 'dom', + exec: function(data, conversionContext) { + return util.createElement(conversionContext.document, 'span'); + } + }, + { + item: 'converter', + from: 'json', + to: 'view', + exec: function(json, context) { + var html = JSON.stringify(json, null, ' ').replace(/\n/g, '<br/>'); + return { + html: '<pre>' + html + '</pre>' + }; + } + }, + { + item: 'converter', + from: 'number', + to: 'string', + exec: function(data) { return '' + data; } + }, + { + item: 'converter', + from: 'boolean', + to: 'string', + exec: function(data) { return '' + data; } + }, + { + item: 'converter', + from: 'undefined', + to: 'string', + exec: function(data) { return ''; } + }, + { + item: 'converter', + from: 'json', + to: 'string', + exec: function(json, conversionContext) { + return JSON.stringify(json, null, ' '); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/converters.js b/devtools/shared/gcli/source/lib/gcli/converters/converters.js new file mode 100644 index 000000000..c054871d6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/converters.js @@ -0,0 +1,280 @@ +/* + * 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 host = require('../util/host'); + +// It's probably easiest to read this bottom to top + +/** + * Best guess at creating a DOM element from random data + */ +var fallbackDomConverter = { + from: '*', + to: 'dom', + exec: function(data, conversionContext) { + return conversionContext.document.createTextNode(data || ''); + } +}; + +/** + * Best guess at creating a string from random data + */ +var fallbackStringConverter = { + from: '*', + to: 'string', + exec: function(data, conversionContext) { + return data == null ? '' : data.toString(); + } +}; + +/** + * Convert a view object to a DOM element + */ +var viewDomConverter = { + item: 'converter', + from: 'view', + to: 'dom', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document); + } +}; + +/** + * Convert a view object to a string + */ +var viewStringConverter = { + item: 'converter', + from: 'view', + to: 'string', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document).textContent; + } +}; + +/** + * Convert a view object to a string + */ +var stringViewStringConverter = { + item: 'converter', + from: 'stringView', + to: 'string', + exec: function(view, conversionContext) { + if (!view.isView) { + view = conversionContext.createView(view); + } + return view.toDom(conversionContext.document).textContent; + } +}; + +/** + * Convert an exception to a DOM element + */ +var errorDomConverter = { + item: 'converter', + from: 'error', + to: 'dom', + exec: function(ex, conversionContext) { + var node = util.createElement(conversionContext.document, 'p'); + node.className = 'gcli-error'; + node.textContent = errorStringConverter.exec(ex, conversionContext); + return node; + } +}; + +/** + * Convert an exception to a string + */ +var errorStringConverter = { + item: 'converter', + from: 'error', + to: 'string', + exec: function(ex, conversionContext) { + if (typeof ex === 'string') { + return ex; + } + if (ex instanceof Error) { + return '' + ex; + } + if (typeof ex.message === 'string') { + return ex.message; + } + return '' + ex; + } +}; + +/** + * Create a new converter by using 2 converters, one after the other + */ +function getChainConverter(first, second) { + if (first.to !== second.from) { + throw new Error('Chain convert impossible: ' + first.to + '!=' + second.from); + } + return { + from: first.from, + to: second.to, + exec: function(data, conversionContext) { + var intermediate = first.exec(data, conversionContext); + return second.exec(intermediate, conversionContext); + } + }; +} + +/** + * A manager for the registered Converters + */ +function Converters() { + // This is where we cache the converters that we know about + this._registered = { + from: {} + }; +} + +/** + * Add a new converter to the cache + */ +Converters.prototype.add = function(converter) { + var fromMatch = this._registered.from[converter.from]; + if (fromMatch == null) { + fromMatch = {}; + this._registered.from[converter.from] = fromMatch; + } + + fromMatch[converter.to] = converter; +}; + +/** + * Remove an existing converter from the cache + */ +Converters.prototype.remove = function(converter) { + var fromMatch = this._registered.from[converter.from]; + if (fromMatch == null) { + return; + } + + if (fromMatch[converter.to] === converter) { + fromMatch[converter.to] = null; + } +}; + +/** + * Work out the best converter that we've got, for a given conversion. + */ +Converters.prototype.get = function(from, to) { + var fromMatch = this._registered.from[from]; + if (fromMatch == null) { + return this._getFallbackConverter(from, to); + } + + var converter = fromMatch[to]; + if (converter == null) { + // Someone is going to love writing a graph search algorithm to work out + // the smallest number of conversions, or perhaps the least 'lossy' + // conversion but for now the only 2 step conversions which we are going to + // special case are foo->view->dom and foo->stringView->string. + if (to === 'dom') { + converter = fromMatch.view; + if (converter != null) { + return getChainConverter(converter, viewDomConverter); + } + } + + if (to === 'string') { + converter = fromMatch.stringView; + if (converter != null) { + return getChainConverter(converter, stringViewStringConverter); + } + converter = fromMatch.view; + if (converter != null) { + return getChainConverter(converter, viewStringConverter); + } + } + + return this._getFallbackConverter(from, to); + } + return converter; +}; + +/** + * Get all the registered converters. Most for debugging + */ +Converters.prototype.getAll = function() { + return Object.keys(this._registered.from).map(function(name) { + return this._registered.from[name]; + }.bind(this)); +}; + +/** + * Helper for get to pick the best fallback converter + */ +Converters.prototype._getFallbackConverter = function(from, to) { + console.error('No converter from ' + from + ' to ' + to + '. Using fallback'); + + if (to === 'dom') { + return fallbackDomConverter; + } + + if (to === 'string') { + return fallbackStringConverter; + } + + throw new Error('No conversion possible from ' + from + ' to ' + to + '.'); +}; + +/** + * Convert some data from one type to another + * @param data The object to convert + * @param from The type of the data right now + * @param to The type that we would like the data in + * @param conversionContext An execution context (i.e. simplified requisition) + * which is often required for access to a document, or createView function + */ +Converters.prototype.convert = function(data, from, to, conversionContext) { + try { + if (from === to) { + return Promise.resolve(data); + } + + var converter = this.get(from, to); + return host.exec(function() { + return converter.exec(data, conversionContext); + }.bind(this)); + } + catch (ex) { + var converter = this.get('error', to); + return host.exec(function() { + return converter.exec(ex, conversionContext); + }.bind(this)); + } +}; + +exports.Converters = Converters; + +/** + * Items for export + */ +exports.items = [ + viewDomConverter, viewStringConverter, stringViewStringConverter, + errorDomConverter, errorStringConverter +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/html.js b/devtools/shared/gcli/source/lib/gcli/converters/html.js new file mode 100644 index 000000000..2dea0eb82 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/html.js @@ -0,0 +1,47 @@ +/* + * 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'); + +/** + * 'html' means a string containing HTML markup. We use innerHTML to inject + * this into a DOM which has security implications, so this module will not + * be used in all implementations. + */ +exports.items = [ + { + item: 'converter', + from: 'html', + to: 'dom', + exec: function(html, conversionContext) { + var div = util.createElement(conversionContext.document, 'div'); + div.innerHTML = html; + return div; + } + }, + { + item: 'converter', + from: 'html', + to: 'string', + exec: function(html, conversionContext) { + var div = util.createElement(conversionContext.document, 'div'); + div.innerHTML = html; + return div.textContent; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/converters/moz.build b/devtools/shared/gcli/source/lib/gcli/converters/moz.build new file mode 100644 index 000000000..d3a649197 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'basic.js', + 'converters.js', + 'html.js', + 'terminal.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/converters/terminal.js b/devtools/shared/gcli/source/lib/gcli/converters/terminal.js new file mode 100644 index 000000000..a2406c689 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/converters/terminal.js @@ -0,0 +1,56 @@ +/* + * 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'); + +/** + * A 'terminal' object is a string or an array of strings, which are typically + * the output from a shell command + */ +exports.items = [ + { + item: 'converter', + from: 'terminal', + to: 'dom', + createTextArea: function(text, conversionContext) { + var node = util.createElement(conversionContext.document, 'textarea'); + node.classList.add('gcli-row-subterminal'); + node.readOnly = true; + node.textContent = text; + return node; + }, + exec: function(data, conversionContext) { + if (Array.isArray(data)) { + var node = util.createElement(conversionContext.document, 'div'); + data.forEach(function(member) { + node.appendChild(this.createTextArea(member, conversionContext)); + }); + return node; + } + return this.createTextArea(data); + } + }, + { + item: 'converter', + from: 'terminal', + to: 'string', + exec: function(data, conversionContext) { + return Array.isArray(data) ? data.join('') : '' + data; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/delegate.js b/devtools/shared/gcli/source/lib/gcli/fields/delegate.js new file mode 100644 index 000000000..a2fa508f0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/delegate.js @@ -0,0 +1,96 @@ +/* + * 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 Field = require('./fields').Field; + +/** + * A field that works with delegate types by delaying resolution until that + * last possible time + */ +function DelegateField(type, options) { + Field.call(this, type, options); + this.options = options; + + this.element = util.createElement(this.document, 'div'); + this.update(); + + this.onFieldChange = util.createEvent('DelegateField.onFieldChange'); +} + +DelegateField.prototype = Object.create(Field.prototype); + +DelegateField.prototype.update = function() { + var subtype = this.type.getType(this.options.requisition.executionContext); + if (typeof subtype.parse !== 'function') { + subtype = this.options.requisition.system.types.createType(subtype); + } + + // It's not clear that we can compare subtypes in this way. + // Perhaps we need a type.equals(...) function + if (subtype === this.subtype) { + return; + } + + if (this.field) { + this.field.destroy(); + } + + this.subtype = subtype; + var fields = this.options.requisition.system.fields; + this.field = fields.get(subtype, this.options); + + util.clearElement(this.element); + this.element.appendChild(this.field.element); +}; + +DelegateField.claim = function(type, context) { + return type.isDelegate ? Field.MATCH : Field.NO_MATCH; +}; + +DelegateField.prototype.destroy = function() { + this.element = undefined; + this.options = undefined; + if (this.field) { + this.field.destroy(); + } + this.subtype = undefined; + Field.prototype.destroy.call(this); +}; + +DelegateField.prototype.setConversion = function(conversion) { + this.field.setConversion(conversion); +}; + +DelegateField.prototype.getConversion = function() { + return this.field.getConversion(); +}; + +Object.defineProperty(DelegateField.prototype, 'isImportant', { + get: function() { + return this.field.isImportant; + }, + enumerable: true +}); + +/** + * Exported items + */ +exports.items = [ + DelegateField +]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/fields.js b/devtools/shared/gcli/source/lib/gcli/fields/fields.js new file mode 100644 index 000000000..c97184731 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/fields.js @@ -0,0 +1,245 @@ +/* + * 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'); + +/** + * A Field is a way to get input for a single parameter. + * This class is designed to be inherited from. It's important that all + * subclasses have a similar constructor signature because they are created + * via Fields.get(...) + * @param type The type to use in conversions + * @param options A set of properties to help fields configure themselves: + * - document: The document we use in calling createElement + * - requisition: The requisition that we're attached to + */ +function Field(type, options) { + this.type = type; + this.document = options.document; + this.requisition = options.requisition; +} + +/** + * Enable registration of fields using addItems + */ +Field.prototype.item = 'field'; + +/** + * Subclasses should assign their element with the DOM node that gets added + * to the 'form'. It doesn't have to be an input node, just something that + * contains it. + */ +Field.prototype.element = undefined; + +/** + * Called from the outside to indicate that the command line has changed and + * the field should update itself + */ +Field.prototype.update = function() { +}; + +/** + * Indicates that this field should drop any resources that it has created + */ +Field.prototype.destroy = function() { + this.messageElement = undefined; + this.document = undefined; + this.requisition = undefined; +}; + +// Note: We could/should probably change Fields from working with Conversions +// to working with Arguments (Tokens), which makes for less calls to parse() + +/** + * Update this field display with the value from this conversion. + * Subclasses should provide an implementation of this function. + */ +Field.prototype.setConversion = function(conversion) { + throw new Error('Field should not be used directly'); +}; + +/** + * Extract a conversion from the values in this field. + * Subclasses should provide an implementation of this function. + */ +Field.prototype.getConversion = function() { + throw new Error('Field should not be used directly'); +}; + +/** + * Set the element where messages and validation errors will be displayed + * @see setMessage() + */ +Field.prototype.setMessageElement = function(element) { + this.messageElement = element; +}; + +/** + * Display a validation message in the UI + */ +Field.prototype.setMessage = function(message) { + if (this.messageElement) { + util.setTextContent(this.messageElement, message || ''); + } +}; + +/** + * Some fields contain information that is more important to the user, for + * example error messages and completion menus. + */ +Field.prototype.isImportant = false; + +/** + * 'static/abstract' method to allow implementations of Field to lay a claim + * to a type. This allows claims of various strength to be weighted up. + * See the Field.*MATCH values. + */ +Field.claim = function(type, context) { + throw new Error('Field should not be used directly'); +}; + +/** + * How good a match is a field for a given type + */ +Field.MATCH = 3; // Good match +Field.DEFAULT = 2; // A default match +Field.BASIC = 1; // OK in an emergency. i.e. assume Strings +Field.NO_MATCH = 0; // This field can't help with the given type + +exports.Field = Field; + + +/** + * A manager for the registered Fields + */ +function Fields() { + // Internal array of known fields + this._fieldCtors = []; +} + +/** + * Add a field definition by field constructor + * @param fieldCtor Constructor function of new Field + */ +Fields.prototype.add = function(fieldCtor) { + if (typeof fieldCtor !== 'function') { + console.error('fields.add erroring on ', fieldCtor); + throw new Error('fields.add requires a Field constructor'); + } + this._fieldCtors.push(fieldCtor); +}; + +/** + * Remove a Field definition + * @param field A previously registered field, specified either with a field + * name or from the field name + */ +Fields.prototype.remove = function(field) { + if (typeof field !== 'string') { + this._fieldCtors = this._fieldCtors.filter(function(test) { + return test !== field; + }); + } + else if (field instanceof Field) { + this.remove(field.name); + } + else { + console.error('fields.remove erroring on ', field); + throw new Error('fields.remove requires an instance of Field'); + } +}; + +/** + * Find the best possible matching field from the specification of the type + * of field required. + * @param type An instance of Type that we will represent + * @param options A set of properties that we should attempt to match, and use + * in the construction of the new field object: + * - document: The document to use in creating new elements + * - requisition: The requisition we're monitoring, + * @return A newly constructed field that best matches the input options + */ +Fields.prototype.get = function(type, options) { + var FieldConstructor; + var highestClaim = -1; + this._fieldCtors.forEach(function(fieldCtor) { + var context = (options.requisition == null) ? + null : options.requisition.executionContext; + var claim = fieldCtor.claim(type, context); + if (claim > highestClaim) { + highestClaim = claim; + FieldConstructor = fieldCtor; + } + }); + + if (!FieldConstructor) { + console.error('Unknown field type ', type, ' in ', this._fieldCtors); + throw new Error('Can\'t find field for ' + type); + } + + if (highestClaim < Field.DEFAULT) { + return new BlankField(type, options); + } + + return new FieldConstructor(type, options); +}; + +/** + * Get all the registered fields. Most for debugging + */ +Fields.prototype.getAll = function() { + return this._fieldCtors.slice(); +}; + +exports.Fields = Fields; + +/** + * For use with delegate types that do not yet have anything to resolve to. + * BlankFields are not for general use. + */ +function BlankField(type, options) { + Field.call(this, type, options); + + this.element = util.createElement(this.document, 'div'); + + this.onFieldChange = util.createEvent('BlankField.onFieldChange'); +} + +BlankField.prototype = Object.create(Field.prototype); + +BlankField.claim = function(type, context) { + return type.name === 'blank' ? Field.MATCH : Field.NO_MATCH; +}; + +BlankField.prototype.destroy = function() { + this.element = undefined; + Field.prototype.destroy.call(this); +}; + +BlankField.prototype.setConversion = function(conversion) { + this.setMessage(conversion.message); +}; + +BlankField.prototype.getConversion = function() { + return this.type.parseString('', this.requisition.executionContext); +}; + +/** + * Items for export + */ +exports.items = [ BlankField ]; diff --git a/devtools/shared/gcli/source/lib/gcli/fields/moz.build b/devtools/shared/gcli/source/lib/gcli/fields/moz.build new file mode 100644 index 000000000..74fa1cc95 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'delegate.js', + 'fields.js', + 'selection.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/fields/selection.js b/devtools/shared/gcli/source/lib/gcli/fields/selection.js new file mode 100644 index 000000000..4f5885777 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/fields/selection.js @@ -0,0 +1,124 @@ +/* + * 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 Menu = require('../ui/menu').Menu; + +var Argument = require('../types/types').Argument; +var Conversion = require('../types/types').Conversion; +var Field = require('./fields').Field; + +/** + * A field that allows selection of one of a number of options + */ +function SelectionField(type, options) { + Field.call(this, type, options); + + this.arg = new Argument(); + + this.menu = new Menu({ + document: this.document, + maxPredictions: Conversion.maxPredictions + }); + this.element = this.menu.element; + + this.onFieldChange = util.createEvent('SelectionField.onFieldChange'); + + // i.e. Register this.onItemClick as the default action for a menu click + this.menu.onItemClick.add(this.itemClicked, this); +} + +SelectionField.prototype = Object.create(Field.prototype); + +SelectionField.claim = function(type, context) { + if (context == null) { + return Field.NO_MATCH; + } + return type.getType(context).hasPredictions ? Field.DEFAULT : Field.NO_MATCH; +}; + +SelectionField.prototype.destroy = function() { + this.menu.onItemClick.remove(this.itemClicked, this); + this.menu.destroy(); + this.menu = undefined; + this.element = undefined; + Field.prototype.destroy.call(this); +}; + +SelectionField.prototype.setConversion = function(conversion) { + this.arg = conversion.arg; + this.setMessage(conversion.message); + + var context = this.requisition.executionContext; + conversion.getPredictions(context).then(function(predictions) { + var items = predictions.map(function(prediction) { + // If the prediction value is an 'item' (that is an object with a name and + // description) then use that, otherwise use the prediction itself, because + // at least that has a name. + return prediction.value && prediction.value.description ? + prediction.value : + prediction; + }, this); + if (this.menu != null) { + this.menu.show(items, conversion.arg.text); + } + }.bind(this)).catch(util.errorHandler); +}; + +SelectionField.prototype.itemClicked = function(ev) { + var arg = new Argument(ev.name, '', ' '); + var context = this.requisition.executionContext; + + this.type.parse(arg, context).then(function(conversion) { + this.onFieldChange({ conversion: conversion }); + this.setMessage(conversion.message); + }.bind(this)).catch(util.errorHandler); +}; + +SelectionField.prototype.getConversion = function() { + // This tweaks the prefix/suffix of the argument to fit + this.arg = this.arg.beget({ text: this.input.value }); + return this.type.parse(this.arg, this.requisition.executionContext); +}; + +/** + * Allow the terminal to use RETURN to chose the current menu item when + * it can't execute the command line + * @return true if an item was 'clicked', false otherwise + */ +SelectionField.prototype.selectChoice = function() { + var selected = this.menu.selected; + if (selected == null) { + return false; + } + + this.itemClicked({ name: selected }); + return true; +}; + +Object.defineProperty(SelectionField.prototype, 'isImportant', { + get: function() { + return this.type.name !== 'command'; + }, + enumerable: true +}); + +/** + * Allow registration and de-registration. + */ +exports.items = [ SelectionField ]; diff --git a/devtools/shared/gcli/source/lib/gcli/index.js b/devtools/shared/gcli/source/lib/gcli/index.js new file mode 100644 index 000000000..0b889b63d --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/index.js @@ -0,0 +1,29 @@ +/* + * 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 Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; + + +var prefSvc = Cc['@mozilla.org/preferences-service;1'] + .getService(Ci.nsIPrefService); +var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); + +exports.hiddenByChromePref = function() { + return !prefBranch.prefHasUserValue('devtools.chrome.enabled'); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/l10n.js b/devtools/shared/gcli/source/lib/gcli/l10n.js new file mode 100644 index 000000000..4d3f36595 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/l10n.js @@ -0,0 +1,74 @@ +/* + * 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 Cc = require("chrome").Cc; +var Ci = require("chrome").Ci; + +var prefSvc = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService); +var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gclicommands.properties"); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookup = function (name) { + try { + return L10N.getStr(name); + } catch (ex) { + throw new Error("Failure in lookup('" + name + "')"); + } +}; + +/** + * An alternative to lookup(). + * <code>l10n.lookup("BLAH") === l10n.propertyLookup.BLAH</code> + * This is particularly nice for templates because you can pass + * <code>l10n:l10n.propertyLookup</code> in the template data and use it + * like <code>${l10n.BLAH}</code> + */ +exports.propertyLookup = new Proxy({}, { + get: function (rcvr, name) { + return exports.lookup(name); + } +}); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookupFormat = function (name, swaps) { + try { + return L10N.getFormatStr(name, ...swaps); + } catch (ex) { + throw new Error("Failure in lookupFormat('" + name + "')"); + } +}; + +/** + * Allow GCLI users to be hidden by the "devtools.chrome.enabled" pref. + * Use it in commands like this: + * <pre> + * name: "somecommand", + * hidden: l10n.hiddenByChromePref(), + * exec: function (args, context) { ... } + * </pre> + */ +exports.hiddenByChromePref = function () { + return !prefBranch.getBoolPref("devtools.chrome.enabled"); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/command.html b/devtools/shared/gcli/source/lib/gcli/languages/command.html new file mode 100644 index 000000000..45b367332 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/command.html @@ -0,0 +1,14 @@ + +<div> + <div class="gcli-row-in" save="${rowinEle}" aria-live="assertive" + onclick="${onclick}" ondblclick="${ondblclick}" + data-command="${output.canonical}"> + <span + save="${promptEle}" + class="gcli-row-prompt ${promptClass}">:</span><span + class="gcli-row-in-typed">${output.typed}</span> + <div class="gcli-row-throbber" save="${throbEle}"></div> + </div> + <div class="gcli-row-out" aria-live="assertive" save="${rowoutEle}"> + </div> +</div> diff --git a/devtools/shared/gcli/source/lib/gcli/languages/command.js b/devtools/shared/gcli/source/lib/gcli/languages/command.js new file mode 100644 index 000000000..58357ce2b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/command.js @@ -0,0 +1,563 @@ +/* + * 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 domtemplate = require('../util/domtemplate'); +var host = require('../util/host'); + +var Status = require('../types/types').Status; +var cli = require('../cli'); +var Requisition = require('../cli').Requisition; +var CommandAssignment = require('../cli').CommandAssignment; +var intro = require('../ui/intro'); + +var RESOLVED = Promise.resolve(true); + +/** + * Various ways in which we need to manipulate the caret/selection position. + * A value of null means we're not expecting a change + */ +var Caret = exports.Caret = { + /** + * We are expecting changes, but we don't need to move the cursor + */ + NO_CHANGE: 0, + + /** + * We want the entire input area to be selected + */ + SELECT_ALL: 1, + + /** + * The whole input has changed - push the cursor to the end + */ + TO_END: 2, + + /** + * A part of the input has changed - push the cursor to the end of the + * changed section + */ + TO_ARG_END: 3 +}; + +/** + * Shared promise for loading command.html + */ +var commandHtmlPromise; + +var commandLanguage = exports.commandLanguage = { + // Language implementation for GCLI commands + item: 'language', + name: 'commands', + prompt: ':', + proportionalFonts: true, + + constructor: function(terminal) { + this.terminal = terminal; + this.document = terminal.document; + this.focusManager = terminal.focusManager; + + var options = this.terminal.options; + this.requisition = options.requisition; + if (this.requisition == null) { + if (options.environment == null) { + options.environment = {}; + options.environment.document = options.document || this.document; + options.environment.window = options.environment.document.defaultView; + } + + this.requisition = new Requisition(terminal.system, options); + } + + // We also keep track of the last known arg text for the current assignment + this.lastText = undefined; + + // Used to effect caret changes. See _processCaretChange() + this._caretChange = null; + + // We keep track of which assignment the cursor is in + this.assignment = this.requisition.getAssignmentAt(0); + + if (commandHtmlPromise == null) { + commandHtmlPromise = host.staticRequire(module, './command.html'); + } + + return commandHtmlPromise.then(function(commandHtml) { + this.commandDom = host.toDom(this.document, commandHtml); + + this.requisition.commandOutputManager.onOutput.add(this.outputted, this); + var mapping = cli.getMapping(this.requisition.executionContext); + mapping.terminal = this.terminal; + + this.requisition.onExternalUpdate.add(this.textChanged, this); + + return this; + }.bind(this)); + }, + + destroy: function() { + var mapping = cli.getMapping(this.requisition.executionContext); + delete mapping.terminal; + + this.requisition.commandOutputManager.onOutput.remove(this.outputted, this); + this.requisition.onExternalUpdate.remove(this.textChanged, this); + + this.terminal = undefined; + this.requisition = undefined; + this.commandDom = undefined; + }, + + textChanged: function() { + if (this.terminal == null) { + return; // This can happen post-destroy() + } + + if (this.terminal._caretChange == null) { + // We weren't expecting a change so this was requested by the hint system + // we should move the cursor to the end of the 'changed section', and the + // best we can do for that right now is the end of the current argument. + this.terminal._caretChange = Caret.TO_ARG_END; + } + + var newStr = this.requisition.toString(); + var input = this.terminal.getInputState(); + + input.typed = newStr; + this._processCaretChange(input); + + // We don't update terminal._previousValue. Should we? + // Shouldn't this really be a function of terminal? + if (this.terminal.inputElement.value !== newStr) { + this.terminal.inputElement.value = newStr; + } + this.terminal.onInputChange({ inputState: input }); + + // We get here for minor things like whitespace change in arg prefix, + // so we ignore anything but an actual value change. + if (this.assignment.arg.text === this.lastText) { + return; + } + + this.lastText = this.assignment.arg.text; + + this.terminal.field.update(); + this.terminal.field.setConversion(this.assignment.conversion); + util.setTextContent(this.terminal.descriptionEle, this.description); + }, + + // Called internally whenever we think that the current assignment might + // have changed, typically on mouse-clicks or key presses. + caretMoved: function(start) { + if (!this.requisition.isUpToDate()) { + return; + } + var newAssignment = this.requisition.getAssignmentAt(start); + if (newAssignment == null) { + return; + } + + if (this.assignment !== newAssignment) { + if (this.assignment.param.type.onLeave) { + this.assignment.param.type.onLeave(this.assignment); + } + + // This can be kicked off either by requisition doing an assign or by + // terminal noticing a cursor movement out of a command, so we should + // check that this really is a new assignment + var isNew = (this.assignment !== newAssignment); + + this.assignment = newAssignment; + this.terminal.updateCompletion().catch(util.errorHandler); + + if (isNew) { + this.updateHints(); + } + + if (this.assignment.param.type.onEnter) { + this.assignment.param.type.onEnter(this.assignment); + } + } + else { + if (this.assignment && this.assignment.param.type.onChange) { + this.assignment.param.type.onChange(this.assignment); + } + } + + // Warning: compare the logic here with the logic in fieldChanged, which + // is slightly different. They should probably be the same + var error = (this.assignment.status === Status.ERROR); + this.focusManager.setError(error); + }, + + // Called whenever the assignment that we're providing help with changes + updateHints: function() { + this.lastText = this.assignment.arg.text; + + var field = this.terminal.field; + if (field) { + field.onFieldChange.remove(this.terminal.fieldChanged, this.terminal); + field.destroy(); + } + + var fields = this.terminal.system.fields; + field = this.terminal.field = fields.get(this.assignment.param.type, { + document: this.terminal.document, + requisition: this.requisition + }); + + this.focusManager.setImportantFieldFlag(field.isImportant); + + field.onFieldChange.add(this.terminal.fieldChanged, this.terminal); + field.setConversion(this.assignment.conversion); + + // Filled in by the template process + this.terminal.errorEle = undefined; + this.terminal.descriptionEle = undefined; + + var contents = this.terminal.tooltipTemplate.cloneNode(true); + domtemplate.template(contents, this.terminal, { + blankNullUndefined: true, + stack: 'terminal.html#tooltip' + }); + + util.clearElement(this.terminal.tooltipElement); + this.terminal.tooltipElement.appendChild(contents); + this.terminal.tooltipElement.style.display = 'block'; + + field.setMessageElement(this.terminal.errorEle); + }, + + /** + * See also handleDownArrow for some symmetry + */ + handleUpArrow: function() { + // If the user is on a valid value, then we increment the value, but if + // they've typed something that's not right we page through predictions + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, 1).then(function() { + this.textChanged(); + this.focusManager.onInputChange(); + return true; + }.bind(this)); + } + + return Promise.resolve(false); + }, + + /** + * See also handleUpArrow for some symmetry + */ + handleDownArrow: function() { + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, -1).then(function() { + this.textChanged(); + this.focusManager.onInputChange(); + return true; + }.bind(this)); + } + + return Promise.resolve(false); + }, + + /** + * RETURN checks status and might exec + */ + handleReturn: function(input) { + // Deny RETURN unless the command might work + if (this.requisition.status !== Status.VALID) { + return Promise.resolve(false); + } + + this.terminal.history.add(input); + this.terminal.unsetChoice().catch(util.errorHandler); + + this.terminal._previousValue = this.terminal.inputElement.value; + this.terminal.inputElement.value = ''; + + return this.requisition.exec().then(function() { + this.textChanged(); + return true; + }.bind(this)); + }, + + /** + * Warning: We get TAB events for more than just the user pressing TAB in our + * input element. + */ + handleTab: function() { + // It's possible for TAB to not change the input, in which case the + // textChanged event will not fire, and the caret move will not be + // processed. So we check that this is done first + this.terminal._caretChange = Caret.TO_ARG_END; + var inputState = this.terminal.getInputState(); + this._processCaretChange(inputState); + + this.terminal._previousValue = this.terminal.inputElement.value; + + // The changes made by complete may happen asynchronously, so after the + // the call to complete() we should avoid making changes before the end + // of the event loop + var index = this.terminal.getChoiceIndex(); + return this.requisition.complete(inputState.cursor, index).then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (!updated) { + return RESOLVED; + } + this.textChanged(); + return this.terminal.unsetChoice(); + }.bind(this)); + }, + + /** + * The input text has changed in some way. + */ + handleInput: function(value) { + this.terminal._caretChange = Caret.NO_CHANGE; + + return this.requisition.update(value).then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (!updated) { + return RESOLVED; + } + this.textChanged(); + return this.terminal.unsetChoice(); + }.bind(this)); + }, + + /** + * Counterpart to |setInput| for moving the cursor. + * @param cursor A JS object shaped like { start: x, end: y } + */ + setCursor: function(cursor) { + this._caretChange = Caret.NO_CHANGE; + this._processCaretChange({ + typed: this.terminal.inputElement.value, + cursor: cursor + }); + }, + + /** + * If this._caretChange === Caret.TO_ARG_END, we alter the input object to move + * the selection start to the end of the current argument. + * @param input An object shaped like { typed:'', cursor: { start:0, end:0 }} + */ + _processCaretChange: function(input) { + var start, end; + switch (this._caretChange) { + case Caret.SELECT_ALL: + start = 0; + end = input.typed.length; + break; + + case Caret.TO_END: + start = input.typed.length; + end = input.typed.length; + break; + + case Caret.TO_ARG_END: + // There could be a fancy way to do this involving assignment/arg math + // but it doesn't seem easy, so we cheat a move the cursor to just before + // the next space, or the end of the input + start = input.cursor.start; + do { + start++; + } + while (start < input.typed.length && input.typed[start - 1] !== ' '); + + end = start; + break; + + default: + start = input.cursor.start; + end = input.cursor.end; + break; + } + + start = (start > input.typed.length) ? input.typed.length : start; + end = (end > input.typed.length) ? input.typed.length : end; + + var newInput = { + typed: input.typed, + cursor: { start: start, end: end } + }; + + if (this.terminal.inputElement.selectionStart !== start) { + this.terminal.inputElement.selectionStart = start; + } + if (this.terminal.inputElement.selectionEnd !== end) { + this.terminal.inputElement.selectionEnd = end; + } + + this.caretMoved(start); + + this._caretChange = null; + return newInput; + }, + + /** + * Calculate the properties required by the template process for completer.html + */ + getCompleterTemplateData: function() { + var input = this.terminal.getInputState(); + var start = input.cursor.start; + var index = this.terminal.getChoiceIndex(); + + return this.requisition.getStateData(start, index).then(function(data) { + // Calculate the statusMarkup required to show wavy lines underneath the + // input text (like that of an inline spell-checker) which used by the + // template process for completer.html + // i.e. s/space/ /g in the string (for HTML display) and status to an + // appropriate class name (i.e. lower cased, prefixed with gcli-in-) + data.statusMarkup.forEach(function(member) { + member.string = member.string.replace(/ /g, '\u00a0'); // i.e. + member.className = 'gcli-in-' + member.status.toString().toLowerCase(); + }, this); + + return data; + }); + }, + + /** + * Called by the onFieldChange event (via the terminal) on the current Field + */ + fieldChanged: function(ev) { + this.requisition.setAssignment(this.assignment, ev.conversion.arg, + { matchPadding: true }).then(function() { + this.textChanged(); + }.bind(this)); + + var isError = ev.conversion.message != null && ev.conversion.message !== ''; + this.focusManager.setError(isError); + }, + + /** + * Monitor for new command executions + */ + outputted: function(ev) { + if (ev.output.hidden) { + return; + } + + var template = this.commandDom.cloneNode(true); + var templateOptions = { stack: 'terminal.html#outputView' }; + + var context = this.requisition.conversionContext; + var data = { + onclick: context.update, + ondblclick: context.updateExec, + language: this, + output: ev.output, + promptClass: (ev.output.error ? 'gcli-row-error' : '') + + (ev.output.completed ? ' gcli-row-complete' : ''), + // Elements attached to this by template(). + rowinEle: null, + rowoutEle: null, + throbEle: null, + promptEle: null + }; + + domtemplate.template(template, data, templateOptions); + + ev.output.promise.then(function() { + var document = data.rowoutEle.ownerDocument; + + if (ev.output.completed) { + data.promptEle.classList.add('gcli-row-complete'); + } + if (ev.output.error) { + data.promptEle.classList.add('gcli-row-error'); + } + + util.clearElement(data.rowoutEle); + + return ev.output.convert('dom', context).then(function(node) { + this.terminal.scrollToBottom(); + data.throbEle.style.display = ev.output.completed ? 'none' : 'block'; + + if (node == null) { + data.promptEle.classList.add('gcli-row-error'); + // TODO: show some error to the user + } + + this._linksToNewTab(node); + data.rowoutEle.appendChild(node); + + var event = document.createEvent('Event'); + event.initEvent('load', true, true); + event.addedElement = node; + node.dispatchEvent(event); + }.bind(this)); + }.bind(this)).catch(console.error); + + this.terminal.addElement(data.rowinEle); + this.terminal.addElement(data.rowoutEle); + this.terminal.scrollToBottom(); + + this.focusManager.outputted(); + }, + + /** + * Find elements with href attributes and add a target=_blank so opened links + * will open in a new window + */ + _linksToNewTab: function(element) { + var links = element.querySelectorAll('*[href]'); + for (var i = 0; i < links.length; i++) { + links[i].setAttribute('target', '_blank'); + } + return element; + }, + + /** + * Show a short introduction to this language + */ + showIntro: function() { + intro.maybeShowIntro(this.requisition.commandOutputManager, + this.requisition.conversionContext); + }, +}; + +/** + * The description (displayed at the top of the hint area) should be blank if + * we're entering the CommandAssignment (because it's obvious) otherwise it's + * the parameter description. + */ +Object.defineProperty(commandLanguage, 'description', { + get: function() { + if (this.assignment == null || ( + this.assignment instanceof CommandAssignment && + this.assignment.value == null)) { + return ''; + } + + return this.assignment.param.manual || this.assignment.param.description; + }, + enumerable: true +}); + +/** + * Present an error message to the hint popup + */ +Object.defineProperty(commandLanguage, 'message', { + get: function() { + return this.assignment.conversion.message; + }, + enumerable: true +}); + +exports.items = [ commandLanguage ]; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/javascript.js b/devtools/shared/gcli/source/lib/gcli/languages/javascript.js new file mode 100644 index 000000000..229cdd4ff --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/javascript.js @@ -0,0 +1,86 @@ +/* + * 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 host = require('../util/host'); +var prism = require('../util/prism').Prism; + +function isMultiline(text) { + return typeof text === 'string' && text.indexOf('\n') > -1; +} + +exports.items = [ + { + // Language implementation for Javascript + item: 'language', + name: 'javascript', + prompt: '>', + + constructor: function(terminal) { + this.document = terminal.document; + this.focusManager = terminal.focusManager; + + this.updateHints(); + }, + + destroy: function() { + this.document = undefined; + }, + + exec: function(input) { + return this.evaluate(input).then(function(response) { + var output = (response.exception != null) ? + response.exception.class : + response.output; + + var isSameString = typeof output === 'string' && + input.substr(1, input.length - 2) === output; + var isSameOther = typeof output !== 'string' && + input === '' + output; + + // Place strings in quotes + if (typeof output === 'string' && response.exception == null) { + if (output.indexOf('\'') === -1) { + output = '\'' + output + '\''; + } + else { + output = output.replace(/\\/, '\\').replace(/"/, '"').replace(/'/, '\''); + output = '"' + output + '"'; + } + } + + var line; + if (isSameString || isSameOther || output === undefined) { + line = input; + } + else if (isMultiline(output)) { + line = input + '\n/*\n' + output + '\n*/'; + } + else { + line = input + ' // ' + output; + } + + var grammar = prism.languages[this.name]; + return prism.highlight(line, grammar, this.name); + }.bind(this)); + }, + + evaluate: function(input) { + return host.script.evaluate(input); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/languages.js b/devtools/shared/gcli/source/lib/gcli/languages/languages.js new file mode 100644 index 000000000..3444c9a8f --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/languages.js @@ -0,0 +1,179 @@ +/* + * 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 RESOLVED = Promise.resolve(true); + +/** + * This is the base implementation for all languages + */ +var baseLanguage = { + item: 'language', + name: undefined, + + constructor: function(terminal) { + }, + + destroy: function() { + }, + + updateHints: function() { + util.clearElement(this.terminal.tooltipElement); + }, + + description: '', + message: '', + caretMoved: function() {}, + + handleUpArrow: function() { + return Promise.resolve(false); + }, + + handleDownArrow: function() { + return Promise.resolve(false); + }, + + handleTab: function() { + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); + }, + + handleInput: function(input) { + if (input === ':') { + return this.terminal.setInput('').then(function() { + return this.terminal.pushLanguage('commands'); + }.bind(this)); + } + + return this.terminal.unsetChoice().then(function() { + return RESOLVED; + }, util.errorHandler); + }, + + handleReturn: function(input) { + var rowoutEle = this.document.createElement('pre'); + rowoutEle.classList.add('gcli-row-out'); + rowoutEle.classList.add('gcli-row-script'); + rowoutEle.setAttribute('aria-live', 'assertive'); + + return this.exec(input).then(function(line) { + rowoutEle.innerHTML = line; + + this.terminal.addElement(rowoutEle); + this.terminal.scrollToBottom(); + + this.focusManager.outputted(); + + this.terminal.unsetChoice().catch(util.errorHandler); + this.terminal.inputElement.value = ''; + }.bind(this)); + }, + + setCursor: function(cursor) { + this.terminal.inputElement.selectionStart = cursor.start; + this.terminal.inputElement.selectionEnd = cursor.end; + }, + + getCompleterTemplateData: function() { + return Promise.resolve({ + statusMarkup: [ + { + string: this.terminal.inputElement.value.replace(/ /g, '\u00a0'), // i.e. + className: 'gcli-in-valid' + } + ], + unclosedJs: false, + directTabText: '', + arrowTabText: '', + emptyParameters: '' + }); + }, + + showIntro: function() { + }, + + exec: function(input) { + throw new Error('Missing implementation of handleReturn() or exec() ' + this.name); + } +}; + +/** + * A manager for the registered Languages + */ +function Languages() { + // This is where we cache the languages that we know about + this._registered = {}; +} + +/** + * Add a new language to the cache + */ +Languages.prototype.add = function(language) { + this._registered[language.name] = language; +}; + +/** + * Remove an existing language from the cache + */ +Languages.prototype.remove = function(language) { + var name = typeof language === 'string' ? language : language.name; + delete this._registered[name]; +}; + +/** + * Get access to the list of known languages + */ +Languages.prototype.getAll = function() { + return Object.keys(this._registered).map(function(name) { + return this._registered[name]; + }.bind(this)); +}; + +/** + * Find a previously registered language + */ +Languages.prototype.createLanguage = function(name, terminal) { + if (name == null) { + name = Object.keys(this._registered)[0]; + } + + var language = (typeof name === 'string') ? this._registered[name] : name; + if (!language) { + console.error('Known languages: ' + Object.keys(this._registered).join(', ')); + throw new Error('Unknown language: \'' + name + '\''); + } + + // clone 'type' + var newInstance = {}; + util.copyProperties(baseLanguage, newInstance); + util.copyProperties(language, newInstance); + + if (typeof newInstance.constructor === 'function') { + var reply = newInstance.constructor(terminal); + return Promise.resolve(reply).then(function() { + return newInstance; + }); + } + else { + return Promise.resolve(newInstance); + } +}; + +exports.Languages = Languages; diff --git a/devtools/shared/gcli/source/lib/gcli/languages/moz.build b/devtools/shared/gcli/source/lib/gcli/languages/moz.build new file mode 100644 index 000000000..e1828a51f --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/languages/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'command.html', + 'command.js', + 'javascript.js', + 'languages.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/moz.build b/devtools/shared/gcli/source/lib/gcli/moz.build new file mode 100644 index 000000000..7b1e6dd2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'cli.js', + 'index.js', + 'l10n.js', + 'settings.js', + 'system.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/completer.js b/devtools/shared/gcli/source/lib/gcli/mozui/completer.js new file mode 100644 index 000000000..fd9a74732 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/completer.js @@ -0,0 +1,151 @@ +/* + * 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 host = require('../util/host'); +var domtemplate = require('../util/domtemplate'); + +var completerHtml = + '<description\n' + + ' xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">\n' + + ' <loop foreach="member in ${statusMarkup}">\n' + + ' <label class="${member.className}" value="${member.string}"></label>\n' + + ' </loop>\n' + + ' <label class="gcli-in-ontab" value="${directTabText}"/>\n' + + ' <label class="gcli-in-todo" foreach="param in ${emptyParameters}" value="${param}"/>\n' + + ' <label class="gcli-in-ontab" value="${arrowTabText}"/>\n' + + ' <label class="gcli-in-closebrace" if="${unclosedJs}" value="}"/>\n' + + '</description>\n'; + +/** + * Completer is an 'input-like' element that sits an input element annotating + * it with visual goodness. + * @param components Object that links to other UI components. GCLI provided: + * - requisition: A GCLI Requisition object whose state is monitored + * - element: Element to use as root + * - autoResize: (default=false): Should we attempt to sync the dimensions of + * the complete element with the input element. + */ +function Completer(components) { + this.requisition = components.requisition; + this.input = { typed: '', cursor: { start: 0, end: 0 } }; + this.choice = 0; + + this.element = components.element; + this.element.classList.add('gcli-in-complete'); + this.element.setAttribute('tabindex', '-1'); + this.element.setAttribute('aria-live', 'polite'); + + this.document = this.element.ownerDocument; + + this.inputter = components.inputter; + + this.inputter.onInputChange.add(this.update, this); + this.inputter.onAssignmentChange.add(this.update, this); + this.inputter.onChoiceChange.add(this.update, this); + + this.autoResize = components.autoResize; + if (this.autoResize) { + this.inputter.onResize.add(this.resized, this); + + var dimensions = this.inputter.getDimensions(); + if (dimensions) { + this.resized(dimensions); + } + } + + this.template = host.toDom(this.document, completerHtml); + // We want the spans to line up without the spaces in the template + util.removeWhitespace(this.template, true); + + this.update(); +} + +/** + * Avoid memory leaks + */ +Completer.prototype.destroy = function() { + this.inputter.onInputChange.remove(this.update, this); + this.inputter.onAssignmentChange.remove(this.update, this); + this.inputter.onChoiceChange.remove(this.update, this); + + if (this.autoResize) { + this.inputter.onResize.remove(this.resized, this); + } + + this.document = undefined; + this.element = undefined; + this.template = undefined; + this.inputter = undefined; +}; + +/** + * Ensure that the completion element is the same size and the inputter element + */ +Completer.prototype.resized = function(ev) { + this.element.style.top = ev.top + 'px'; + this.element.style.height = ev.height + 'px'; + this.element.style.lineHeight = ev.height + 'px'; + this.element.style.left = ev.left + 'px'; + this.element.style.width = ev.width + 'px'; +}; + +/** + * Bring the completion element up to date with what the requisition says + */ +Completer.prototype.update = function(ev) { + this.choice = (ev && ev.choice != null) ? ev.choice : 0; + + this._getCompleterTemplateData().then(function(data) { + if (this.template == null) { + return; // destroy() has been called + } + + var template = this.template.cloneNode(true); + domtemplate.template(template, data, { stack: 'completer.html' }); + + util.clearElement(this.element); + while (template.hasChildNodes()) { + this.element.appendChild(template.firstChild); + } + }.bind(this)); +}; + +/** + * Calculate the properties required by the template process for completer.html + */ +Completer.prototype._getCompleterTemplateData = function() { + var input = this.inputter.getInputState(); + var start = input.cursor.start; + + return this.requisition.getStateData(start, this.choice).then(function(data) { + // Calculate the statusMarkup required to show wavy lines underneath the + // input text (like that of an inline spell-checker) which used by the + // template process for completer.html + // i.e. s/space/ /g in the string (for HTML display) and status to an + // appropriate class name (i.e. lower cased, prefixed with gcli-in-) + data.statusMarkup.forEach(function(member) { + member.string = member.string.replace(/ /g, '\u00a0'); // i.e. + member.className = 'gcli-in-' + member.status.toString().toLowerCase(); + }, this); + + return data; + }); +}; + +exports.Completer = Completer; diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js b/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js new file mode 100644 index 000000000..3810c2e8c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/inputter.js @@ -0,0 +1,657 @@ +/* + * 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 KeyEvent = require('../util/util').KeyEvent; + +var Status = require('../types/types').Status; +var History = require('../ui/history').History; + +var RESOLVED = Promise.resolve(true); + +/** + * A wrapper to take care of the functions concerning an input element + * @param components Object that links to other UI components. GCLI provided: + * - requisition + * - focusManager + * - element + */ +function Inputter(components) { + this.requisition = components.requisition; + this.focusManager = components.focusManager; + + this.element = components.element; + this.element.classList.add('gcli-in-input'); + this.element.spellcheck = false; + + this.document = this.element.ownerDocument; + + // Used to distinguish focus from TAB in CLI. See onKeyUp() + this.lastTabDownAt = 0; + + // Used to effect caret changes. See _processCaretChange() + this._caretChange = null; + + // Ensure that TAB/UP/DOWN isn't handled by the browser + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.element.addEventListener('keydown', this.onKeyDown, false); + this.element.addEventListener('keyup', this.onKeyUp, false); + + // Setup History + this.history = new History(); + this._scrollingThroughHistory = false; + + // Used when we're selecting which prediction to complete with + this._choice = null; + this.onChoiceChange = util.createEvent('Inputter.onChoiceChange'); + + // Cursor position affects hint severity + this.onMouseUp = this.onMouseUp.bind(this); + this.element.addEventListener('mouseup', this.onMouseUp, false); + + if (this.focusManager) { + this.focusManager.addMonitoredElement(this.element, 'input'); + } + + // Initially an asynchronous completion isn't in-progress + this._completed = RESOLVED; + + this.textChanged = this.textChanged.bind(this); + + this.outputted = this.outputted.bind(this); + this.requisition.commandOutputManager.onOutput.add(this.outputted, this); + + this.assignment = this.requisition.getAssignmentAt(0); + this.onAssignmentChange = util.createEvent('Inputter.onAssignmentChange'); + this.onInputChange = util.createEvent('Inputter.onInputChange'); + + this.onResize = util.createEvent('Inputter.onResize'); + this.onWindowResize = this.onWindowResize.bind(this); + this.document.defaultView.addEventListener('resize', this.onWindowResize, false); + this.requisition.onExternalUpdate.add(this.textChanged, this); + + this._previousValue = undefined; + this.requisition.update(this.element.value || ''); +} + +/** + * Avoid memory leaks + */ +Inputter.prototype.destroy = function() { + this.document.defaultView.removeEventListener('resize', this.onWindowResize, false); + + this.requisition.commandOutputManager.onOutput.remove(this.outputted, this); + this.requisition.onExternalUpdate.remove(this.textChanged, this); + if (this.focusManager) { + this.focusManager.removeMonitoredElement(this.element, 'input'); + } + + this.element.removeEventListener('mouseup', this.onMouseUp, false); + this.element.removeEventListener('keydown', this.onKeyDown, false); + this.element.removeEventListener('keyup', this.onKeyUp, false); + + this.history.destroy(); + + if (this.style) { + this.style.parentNode.removeChild(this.style); + this.style = undefined; + } + + this.textChanged = undefined; + this.outputted = undefined; + this.onMouseUp = undefined; + this.onKeyDown = undefined; + this.onKeyUp = undefined; + this.onWindowResize = undefined; + this.tooltip = undefined; + this.document = undefined; + this.element = undefined; +}; + +/** + * Make ourselves visually similar to the input element, and make the input + * element transparent so our background shines through + */ +Inputter.prototype.onWindowResize = function() { + // Mochitest sometimes causes resize after shutdown. See Bug 743190 + if (!this.element) { + return; + } + + this.onResize(this.getDimensions()); +}; + +/** + * Make ourselves visually similar to the input element, and make the input + * element transparent so our background shines through + */ +Inputter.prototype.getDimensions = function() { + var fixedLoc = {}; + var currentElement = this.element.parentNode; + while (currentElement && currentElement.nodeName !== '#document') { + var style = this.document.defaultView.getComputedStyle(currentElement, ''); + if (style) { + var position = style.getPropertyValue('position'); + if (position === 'absolute' || position === 'fixed') { + var bounds = currentElement.getBoundingClientRect(); + fixedLoc.top = bounds.top; + fixedLoc.left = bounds.left; + break; + } + } + currentElement = currentElement.parentNode; + } + + var rect = this.element.getBoundingClientRect(); + return { + top: rect.top - (fixedLoc.top || 0) + 1, + height: rect.bottom - rect.top - 1, + left: rect.left - (fixedLoc.left || 0) + 2, + width: rect.right - rect.left + }; +}; + +/** + * Pass 'outputted' events on to the focus manager + */ +Inputter.prototype.outputted = function() { + if (this.focusManager) { + this.focusManager.outputted(); + } +}; + +/** + * Handler for the input-element.onMouseUp event + */ +Inputter.prototype.onMouseUp = function(ev) { + this._checkAssignment(); +}; + +/** + * Function called when we think the text might have changed + */ +Inputter.prototype.textChanged = function() { + if (!this.document) { + return; // This can happen post-destroy() + } + + if (this._caretChange == null) { + // We weren't expecting a change so this was requested by the hint system + // we should move the cursor to the end of the 'changed section', and the + // best we can do for that right now is the end of the current argument. + this._caretChange = Caret.TO_ARG_END; + } + + var newStr = this.requisition.toString(); + var input = this.getInputState(); + + input.typed = newStr; + this._processCaretChange(input); + + if (this.element.value !== newStr) { + this.element.value = newStr; + } + this.onInputChange({ inputState: input }); + + this.tooltip.textChanged(); +}; + +/** + * Various ways in which we need to manipulate the caret/selection position. + * A value of null means we're not expecting a change + */ +var Caret = { + /** + * We are expecting changes, but we don't need to move the cursor + */ + NO_CHANGE: 0, + + /** + * We want the entire input area to be selected + */ + SELECT_ALL: 1, + + /** + * The whole input has changed - push the cursor to the end + */ + TO_END: 2, + + /** + * A part of the input has changed - push the cursor to the end of the + * changed section + */ + TO_ARG_END: 3 +}; + +/** + * If this._caretChange === Caret.TO_ARG_END, we alter the input object to move + * the selection start to the end of the current argument. + * @param input An object shaped like { typed:'', cursor: { start:0, end:0 }} + */ +Inputter.prototype._processCaretChange = function(input) { + var start, end; + switch (this._caretChange) { + case Caret.SELECT_ALL: + start = 0; + end = input.typed.length; + break; + + case Caret.TO_END: + start = input.typed.length; + end = input.typed.length; + break; + + case Caret.TO_ARG_END: + // There could be a fancy way to do this involving assignment/arg math + // but it doesn't seem easy, so we cheat a move the cursor to just before + // the next space, or the end of the input + start = input.cursor.start; + do { + start++; + } + while (start < input.typed.length && input.typed[start - 1] !== ' '); + + end = start; + break; + + default: + start = input.cursor.start; + end = input.cursor.end; + break; + } + + start = (start > input.typed.length) ? input.typed.length : start; + end = (end > input.typed.length) ? input.typed.length : end; + + var newInput = { + typed: input.typed, + cursor: { start: start, end: end } + }; + + if (this.element.selectionStart !== start) { + this.element.selectionStart = start; + } + if (this.element.selectionEnd !== end) { + this.element.selectionEnd = end; + } + + this._checkAssignment(start); + + this._caretChange = null; + return newInput; +}; + +/** + * To be called internally whenever we think that the current assignment might + * have changed, typically on mouse-clicks or key presses. + * @param start Optional - if specified, the cursor position to use in working + * out the current assignment. This is needed because setting the element + * selection start is only recognised when the event loop has finished + */ +Inputter.prototype._checkAssignment = function(start) { + if (start == null) { + start = this.element.selectionStart; + } + if (!this.requisition.isUpToDate()) { + return; + } + var newAssignment = this.requisition.getAssignmentAt(start); + if (newAssignment == null) { + return; + } + if (this.assignment !== newAssignment) { + if (this.assignment.param.type.onLeave) { + this.assignment.param.type.onLeave(this.assignment); + } + + this.assignment = newAssignment; + this.onAssignmentChange({ assignment: this.assignment }); + + if (this.assignment.param.type.onEnter) { + this.assignment.param.type.onEnter(this.assignment); + } + } + else { + if (this.assignment && this.assignment.param.type.onChange) { + this.assignment.param.type.onChange(this.assignment); + } + } + + // This is slightly nasty - the focusManager generally relies on people + // telling it what it needs to know (which makes sense because the event + // system to do it with events would be unnecessarily complex). However + // requisition doesn't know about the focusManager either. So either one + // needs to know about the other, or a third-party needs to break the + // deadlock. These 2 lines are all we're quibbling about, so for now we hack + if (this.focusManager) { + var error = (this.assignment.status === Status.ERROR); + this.focusManager.setError(error); + } +}; + +/** + * Set the input field to a value, for external use. + * This function updates the data model. It sets the caret to the end of the + * input. It does not make any similarity checks so calling this function with + * it's current value resets the cursor position. + * It does not execute the input or affect the history. + * This function should not be called internally, by Inputter and never as a + * result of a keyboard event on this.element or bug 676520 could be triggered. + */ +Inputter.prototype.setInput = function(str) { + this._caretChange = Caret.TO_END; + return this.requisition.update(str).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); +}; + +/** + * Counterpart to |setInput| for moving the cursor. + * @param cursor An object shaped like { start: x, end: y } + */ +Inputter.prototype.setCursor = function(cursor) { + this._caretChange = Caret.NO_CHANGE; + this._processCaretChange({ typed: this.element.value, cursor: cursor }); + return RESOLVED; +}; + +/** + * Focus the input element + */ +Inputter.prototype.focus = function() { + this.element.focus(); + this._checkAssignment(); +}; + +/** + * Ensure certain keys (arrows, tab, etc) that we would like to handle + * are not handled by the browser + */ +Inputter.prototype.onKeyDown = function(ev) { + if (ev.keyCode === KeyEvent.DOM_VK_UP || ev.keyCode === KeyEvent.DOM_VK_DOWN) { + ev.preventDefault(); + return; + } + + // The following keys do not affect the state of the command line so we avoid + // informing the focusManager about keyboard events that involve these keys + if (ev.keyCode === KeyEvent.DOM_VK_F1 || + ev.keyCode === KeyEvent.DOM_VK_ESCAPE || + ev.keyCode === KeyEvent.DOM_VK_UP || + ev.keyCode === KeyEvent.DOM_VK_DOWN) { + return; + } + + if (this.focusManager) { + this.focusManager.onInputChange(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_TAB) { + this.lastTabDownAt = 0; + if (!ev.shiftKey) { + ev.preventDefault(); + // Record the timestamp of this TAB down so onKeyUp can distinguish + // focus from TAB in the CLI. + this.lastTabDownAt = ev.timeStamp; + } + if (ev.metaKey || ev.altKey || ev.crtlKey) { + if (this.document.commandDispatcher) { + this.document.commandDispatcher.advanceFocus(); + } + else { + this.element.blur(); + } + } + } +}; + +/** + * Handler for use with DOM events, which just calls the promise enabled + * handleKeyUp function but checks the exit state of the promise so we know + * if something went wrong. + */ +Inputter.prototype.onKeyUp = function(ev) { + this.handleKeyUp(ev).catch(util.errorHandler); +}; + +/** + * The main keyboard processing loop + * @return A promise that resolves (to undefined) when the actions kicked off + * by this handler are completed. + */ +Inputter.prototype.handleKeyUp = function(ev) { + if (this.focusManager && ev.keyCode === KeyEvent.DOM_VK_F1) { + this.focusManager.helpRequest(); + return RESOLVED; + } + + if (this.focusManager && ev.keyCode === KeyEvent.DOM_VK_ESCAPE) { + this.focusManager.removeHelp(); + return RESOLVED; + } + + if (ev.keyCode === KeyEvent.DOM_VK_UP) { + return this._handleUpArrow(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_DOWN) { + return this._handleDownArrow(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_RETURN) { + return this._handleReturn(); + } + + if (ev.keyCode === KeyEvent.DOM_VK_TAB && !ev.shiftKey) { + return this._handleTab(ev); + } + + if (this._previousValue === this.element.value) { + return RESOLVED; + } + + this._scrollingThroughHistory = false; + this._caretChange = Caret.NO_CHANGE; + + this._completed = this.requisition.update(this.element.value); + this._previousValue = this.element.value; + + return this._completed.then(function() { + // Abort UI changes if this UI update has been overtaken + if (this._previousValue === this.element.value) { + this._choice = null; + this.textChanged(); + this.onChoiceChange({ choice: this._choice }); + } + }.bind(this)); +}; + +/** + * See also _handleDownArrow for some symmetry + */ +Inputter.prototype._handleUpArrow = function() { + if (this.tooltip && this.tooltip.isMenuShowing) { + this.changeChoice(-1); + return RESOLVED; + } + + if (this.element.value === '' || this._scrollingThroughHistory) { + this._scrollingThroughHistory = true; + return this.requisition.update(this.history.backward()).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); + } + + // If the user is on a valid value, then we increment the value, but if + // they've typed something that's not right we page through predictions + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, 1).then(function() { + // See notes on focusManager.onInputChange in onKeyDown + this.textChanged(); + if (this.focusManager) { + this.focusManager.onInputChange(); + } + }.bind(this)); + } + + this.changeChoice(-1); + return RESOLVED; +}; + +/** + * See also _handleUpArrow for some symmetry + */ +Inputter.prototype._handleDownArrow = function() { + if (this.tooltip && this.tooltip.isMenuShowing) { + this.changeChoice(+1); + return RESOLVED; + } + + if (this.element.value === '' || this._scrollingThroughHistory) { + this._scrollingThroughHistory = true; + return this.requisition.update(this.history.forward()).then(function(updated) { + this.textChanged(); + return updated; + }.bind(this)); + } + + // See notes above for the UP key + if (this.assignment.getStatus() === Status.VALID) { + return this.requisition.nudge(this.assignment, -1).then(function() { + // See notes on focusManager.onInputChange in onKeyDown + this.textChanged(); + if (this.focusManager) { + this.focusManager.onInputChange(); + } + }.bind(this)); + } + + this.changeChoice(+1); + return RESOLVED; +}; + +/** + * RETURN checks status and might exec + */ +Inputter.prototype._handleReturn = function() { + // Deny RETURN unless the command might work + if (this.requisition.status === Status.VALID) { + this._scrollingThroughHistory = false; + this.history.add(this.element.value); + + return this.requisition.exec().then(function() { + this.textChanged(); + }.bind(this)); + } + + // If we can't execute the command, but there is a menu choice to use + // then use it. + if (!this.tooltip.selectChoice()) { + this.focusManager.setError(true); + } + + this._choice = null; + return RESOLVED; +}; + +/** + * Warning: We get TAB events for more than just the user pressing TAB in our + * input element. + */ +Inputter.prototype._handleTab = function(ev) { + // Being able to complete 'nothing' is OK if there is some context, but + // when there is nothing on the command line it just looks bizarre. + var hasContents = (this.element.value.length > 0); + + // If the TAB keypress took the cursor from another field to this one, + // then they get the keydown/keypress, and we get the keyup. In this + // case we don't want to do any completion. + // If the time of the keydown/keypress of TAB was close (i.e. within + // 1 second) to the time of the keyup then we assume that we got them + // both, and do the completion. + if (hasContents && this.lastTabDownAt + 1000 > ev.timeStamp) { + // It's possible for TAB to not change the input, in which case the caret + // move will not be processed. So we check that this is done first + this._caretChange = Caret.TO_ARG_END; + var inputState = this.getInputState(); + this._processCaretChange(inputState); + + if (this._choice == null) { + this._choice = 0; + } + + // The changes made by complete may happen asynchronously, so after the + // the call to complete() we should avoid making changes before the end + // of the event loop + this._completed = this.requisition.complete(inputState.cursor, + this._choice); + this._previousValue = this.element.value; + } + this.lastTabDownAt = 0; + this._scrollingThroughHistory = false; + + return this._completed.then(function(updated) { + // Abort UI changes if this UI update has been overtaken + if (updated) { + this.textChanged(); + this._choice = null; + this.onChoiceChange({ choice: this._choice }); + } + }.bind(this)); +}; + +/** + * Used by onKeyUp for UP/DOWN to change the current choice from an options + * menu. + */ +Inputter.prototype.changeChoice = function(amount) { + if (this._choice == null) { + this._choice = 0; + } + // There's an annoying up is down thing here, the menu is presented + // with the zeroth index at the top working down, so the UP arrow needs + // pick the choice below because we're working down + this._choice += amount; + this.onChoiceChange({ choice: this._choice }); +}; + +/** + * Pull together an input object, which may include XUL hacks + */ +Inputter.prototype.getInputState = function() { + var input = { + typed: this.element.value, + cursor: { + start: this.element.selectionStart, + end: this.element.selectionEnd + } + }; + + // Workaround for potential XUL bug 676520 where textbox gives incorrect + // values for its content + if (input.typed == null) { + input = { typed: '', cursor: { start: 0, end: 0 } }; + } + + return input; +}; + +exports.Inputter = Inputter; diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/moz.build b/devtools/shared/gcli/source/lib/gcli/mozui/moz.build new file mode 100644 index 000000000..af76e0d99 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'completer.js', + 'inputter.js', + 'tooltip.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js b/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js new file mode 100644 index 000000000..f72900a80 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/mozui/tooltip.js @@ -0,0 +1,298 @@ +/* + * 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 host = require('../util/host'); +var domtemplate = require('../util/domtemplate'); + +var CommandAssignment = require('../cli').CommandAssignment; + +var tooltipHtml = + '<div class="gcli-tt" aria-live="polite">\n' + + ' <div class="gcli-tt-description" save="${descriptionEle}">${description}</div>\n' + + ' ${field.element}\n' + + ' <div class="gcli-tt-error" save="${errorEle}">${assignment.conversion.message}</div>\n' + + ' <div class="gcli-tt-highlight" save="${highlightEle}"></div>\n' + + '</div>'; + +/** + * A widget to display an inline dialog which allows the user to fill out + * the arguments to a command. + * @param components Object that links to other UI components. GCLI provided: + * - requisition: The Requisition to fill out + * - inputter: An instance of Inputter + * - focusManager: Component to manage hiding/showing this element + * - panelElement (optional): The element to show/hide on visibility events + * - element: The root element to populate + */ +function Tooltip(components) { + this.inputter = components.inputter; + this.requisition = components.requisition; + this.focusManager = components.focusManager; + + this.element = components.element; + this.element.classList.add('gcliterm-tooltip'); + this.document = this.element.ownerDocument; + + this.panelElement = components.panelElement; + if (this.panelElement) { + this.panelElement.classList.add('gcli-panel-hide'); + this.focusManager.onVisibilityChange.add(this.visibilityChanged, this); + } + this.focusManager.addMonitoredElement(this.element, 'tooltip'); + + // We cache the fields we create so we can destroy them later + this.fields = []; + + this.template = host.toDom(this.document, tooltipHtml); + this.templateOptions = { blankNullUndefined: true, stack: 'tooltip.html' }; + + this.inputter.onChoiceChange.add(this.choiceChanged, this); + this.inputter.onAssignmentChange.add(this.assignmentChanged, this); + + // We keep a track of which assignment the cursor is in + this.assignment = undefined; + this.assignmentChanged({ assignment: this.inputter.assignment }); + + // We also keep track of the last known arg text for the current assignment + this.lastText = undefined; +} + +/** + * Avoid memory leaks + */ +Tooltip.prototype.destroy = function() { + this.inputter.onAssignmentChange.remove(this.assignmentChanged, this); + this.inputter.onChoiceChange.remove(this.choiceChanged, this); + + if (this.panelElement) { + this.focusManager.onVisibilityChange.remove(this.visibilityChanged, this); + } + this.focusManager.removeMonitoredElement(this.element, 'tooltip'); + + if (this.style) { + this.style.parentNode.removeChild(this.style); + this.style = undefined; + } + + this.field.onFieldChange.remove(this.fieldChanged, this); + this.field.destroy(); + + this.lastText = undefined; + this.assignment = undefined; + + this.errorEle = undefined; + this.descriptionEle = undefined; + this.highlightEle = undefined; + + this.document = undefined; + this.element = undefined; + this.panelElement = undefined; + this.template = undefined; +}; + +/** + * The inputter acts on UP/DOWN if there is a menu showing + */ +Object.defineProperty(Tooltip.prototype, 'isMenuShowing', { + get: function() { + return this.focusManager.isTooltipVisible && + this.field != null && + this.field.menu != null; + }, + enumerable: true +}); + +/** + * Called whenever the assignment that we're providing help with changes + */ +Tooltip.prototype.assignmentChanged = function(ev) { + // This can be kicked off either by requisition doing an assign or by + // inputter noticing a cursor movement out of a command, so we should check + // that this really is a new assignment + if (this.assignment === ev.assignment) { + return; + } + + this.assignment = ev.assignment; + this.lastText = this.assignment.arg.text; + + if (this.field) { + this.field.onFieldChange.remove(this.fieldChanged, this); + this.field.destroy(); + } + + this.field = this.requisition.system.fields.get(this.assignment.param.type, { + document: this.document, + requisition: this.requisition + }); + + this.focusManager.setImportantFieldFlag(this.field.isImportant); + + this.field.onFieldChange.add(this.fieldChanged, this); + this.field.setConversion(this.assignment.conversion); + + // Filled in by the template process + this.errorEle = undefined; + this.descriptionEle = undefined; + this.highlightEle = undefined; + + var contents = this.template.cloneNode(true); + domtemplate.template(contents, this, this.templateOptions); + util.clearElement(this.element); + this.element.appendChild(contents); + this.element.style.display = 'block'; + + this.field.setMessageElement(this.errorEle); + + this._updatePosition(); +}; + +/** + * Forward the event to the current field + */ +Tooltip.prototype.choiceChanged = function(ev) { + if (this.field && this.field.menu) { + var conversion = this.assignment.conversion; + var context = this.requisition.executionContext; + conversion.constrainPredictionIndex(context, ev.choice).then(function(choice) { + this.field.menu._choice = choice; + this.field.menu._updateHighlight(); + }.bind(this)).catch(util.errorHandler); + } +}; + +/** + * Allow the inputter to use RETURN to chose the current menu item when + * it can't execute the command line + * @return true if there was a selection to use, false otherwise + */ +Tooltip.prototype.selectChoice = function(ev) { + if (this.field && this.field.selectChoice) { + return this.field.selectChoice(); + } + return false; +}; + +/** + * Called by the onFieldChange event on the current Field + */ +Tooltip.prototype.fieldChanged = function(ev) { + this.requisition.setAssignment(this.assignment, ev.conversion.arg, + { matchPadding: true }); + + var isError = ev.conversion.message != null && ev.conversion.message !== ''; + this.focusManager.setError(isError); + + // Nasty hack, the inputter won't know about the text change yet, so it will + // get it's calculations wrong. We need to wait until the current set of + // changes has had a chance to propagate + this.document.defaultView.setTimeout(function() { + this.inputter.focus(); + }.bind(this), 10); +}; + +/** + * Called by the Inputter when the text changes + */ +Tooltip.prototype.textChanged = function() { + // We get here for minor things like whitespace change in arg prefix, + // so we ignore anything but an actual value change. + if (this.assignment.arg.text === this.lastText) { + return; + } + + this.lastText = this.assignment.arg.text; + + this.field.setConversion(this.assignment.conversion); + util.setTextContent(this.descriptionEle, this.description); + + this._updatePosition(); +}; + +/** + * Called to move the tooltip to the correct horizontal position + */ +Tooltip.prototype._updatePosition = function() { + var dimensions = this.getDimensionsOfAssignment(); + + // 10 is roughly the width of a char + if (this.panelElement) { + this.panelElement.style.left = (dimensions.start * 10) + 'px'; + } + + this.focusManager.updatePosition(dimensions); +}; + +/** + * Returns a object containing 'start' and 'end' properties which identify the + * number of pixels from the left hand edge of the input element that represent + * the text portion of the current assignment. + */ +Tooltip.prototype.getDimensionsOfAssignment = function() { + var before = ''; + var assignments = this.requisition.getAssignments(true); + for (var i = 0; i < assignments.length; i++) { + if (assignments[i] === this.assignment) { + break; + } + before += assignments[i].toString(); + } + before += this.assignment.arg.prefix; + + var startChar = before.length; + before += this.assignment.arg.text; + var endChar = before.length; + + return { start: startChar, end: endChar }; +}; + +/** + * The description (displayed at the top of the hint area) should be blank if + * we're entering the CommandAssignment (because it's obvious) otherwise it's + * the parameter description. + */ +Object.defineProperty(Tooltip.prototype, 'description', { + get: function() { + if (this.assignment instanceof CommandAssignment && + this.assignment.value == null) { + return ''; + } + + return this.assignment.param.manual || this.assignment.param.description; + }, + enumerable: true +}); + +/** + * Tweak CSS to show/hide the output + */ +Tooltip.prototype.visibilityChanged = function(ev) { + if (!this.panelElement) { + return; + } + + if (ev.tooltipVisible) { + this.panelElement.classList.remove('gcli-panel-hide'); + } + else { + this.panelElement.classList.add('gcli-panel-hide'); + } +}; + +exports.Tooltip = Tooltip; diff --git a/devtools/shared/gcli/source/lib/gcli/settings.js b/devtools/shared/gcli/source/lib/gcli/settings.js new file mode 100644 index 000000000..29e608cbd --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/settings.js @@ -0,0 +1,284 @@ +/* + * 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 imports = {}; + +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; +var Cu = require('chrome').Cu; + +var XPCOMUtils = Cu.import('resource://gre/modules/XPCOMUtils.jsm', {}).XPCOMUtils; +var Services = require("Services"); + +XPCOMUtils.defineLazyGetter(imports, 'prefBranch', function() { + var prefService = Cc['@mozilla.org/preferences-service;1'] + .getService(Ci.nsIPrefService); + return prefService.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); +}); + +XPCOMUtils.defineLazyGetter(imports, 'supportsString', function() { + return Cc['@mozilla.org/supports-string;1'] + .createInstance(Ci.nsISupportsString); +}); + +var util = require('./util/util'); + +/** + * All local settings have this prefix when used in Firefox + */ +var DEVTOOLS_PREFIX = 'devtools.gcli.'; + +/** + * A manager for the registered Settings + */ +function Settings(types, settingValues) { + this._types = types; + + if (settingValues != null) { + throw new Error('settingValues is not supported when writing to prefs'); + } + + // Collection of preferences for sorted access + this._settingsAll = []; + + // Collection of preferences for fast indexed access + this._settingsMap = new Map(); + + // Flag so we know if we've read the system preferences + this._hasReadSystem = false; + + // Event for use to detect when the list of settings changes + this.onChange = util.createEvent('Settings.onChange'); +} + +/** + * Load system prefs if they've not been loaded already + * @return true + */ +Settings.prototype._readSystem = function() { + if (this._hasReadSystem) { + return; + } + + imports.prefBranch.getChildList('').forEach(function(name) { + var setting = new Setting(this, name); + this._settingsAll.push(setting); + this._settingsMap.set(name, setting); + }.bind(this)); + + this._settingsAll.sort(function(s1, s2) { + return s1.name.localeCompare(s2.name); + }.bind(this)); + + this._hasReadSystem = true; +}; + +/** + * Get an array containing all known Settings filtered to match the given + * filter (string) at any point in the name of the setting + */ +Settings.prototype.getAll = function(filter) { + this._readSystem(); + + if (filter == null) { + return this._settingsAll; + } + + return this._settingsAll.filter(function(setting) { + return setting.name.indexOf(filter) !== -1; + }.bind(this)); +}; + +/** + * Add a new setting + */ +Settings.prototype.add = function(prefSpec) { + var setting = new Setting(this, prefSpec); + + if (this._settingsMap.has(setting.name)) { + // Once exists already, we're going to need to replace it in the array + for (var i = 0; i < this._settingsAll.length; i++) { + if (this._settingsAll[i].name === setting.name) { + this._settingsAll[i] = setting; + } + } + } + + this._settingsMap.set(setting.name, setting); + this.onChange({ added: setting.name }); + + return setting; +}; + +/** + * Getter for an existing setting. Generally use of this function should be + * avoided. Systems that define a setting should export it if they wish it to + * be available to the outside, or not otherwise. Use of this function breaks + * that boundary and also hides dependencies. Acceptable uses include testing + * and embedded uses of GCLI that pre-define all settings (e.g. Firefox) + * @param name The name of the setting to fetch + * @return The found Setting object, or undefined if the setting was not found + */ +Settings.prototype.get = function(name) { + // We might be able to give the answer without needing to read all system + // settings if this is an internal setting + var found = this._settingsMap.get(name); + if (!found) { + found = this._settingsMap.get(DEVTOOLS_PREFIX + name); + } + + if (found) { + return found; + } + + if (this._hasReadSystem) { + return undefined; + } + else { + this._readSystem(); + found = this._settingsMap.get(name); + if (!found) { + found = this._settingsMap.get(DEVTOOLS_PREFIX + name); + } + return found; + } +}; + +/** + * Remove a setting. A no-op in this case + */ +Settings.prototype.remove = function() { +}; + +exports.Settings = Settings; + +/** + * A class to wrap up the properties of a Setting. + * @see toolkit/components/viewconfig/content/config.js + */ +function Setting(settings, prefSpec) { + this._settings = settings; + if (typeof prefSpec === 'string') { + // We're coming from getAll() i.e. a full listing of prefs + this.name = prefSpec; + this.description = ''; + } + else { + // A specific addition by GCLI + this.name = DEVTOOLS_PREFIX + prefSpec.name; + + if (prefSpec.ignoreTypeDifference !== true && prefSpec.type) { + if (this.type.name !== prefSpec.type) { + throw new Error('Locally declared type (' + prefSpec.type + ') != ' + + 'Mozilla declared type (' + this.type.name + ') for ' + this.name); + } + } + + this.description = prefSpec.description; + } + + this.onChange = util.createEvent('Setting.onChange'); +} + +/** + * Reset this setting to it's initial default value + */ +Setting.prototype.setDefault = function() { + imports.prefBranch.clearUserPref(this.name); + Services.prefs.savePrefFile(null); +}; + +/** + * What type is this property: boolean/integer/string? + */ +Object.defineProperty(Setting.prototype, 'type', { + get: function() { + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + return this._settings._types.createType('boolean'); + + case imports.prefBranch.PREF_INT: + return this._settings._types.createType('number'); + + case imports.prefBranch.PREF_STRING: + return this._settings._types.createType('string'); + + default: + throw new Error('Unknown type for ' + this.name); + } + }, + enumerable: true +}); + +/** + * What type is this property: boolean/integer/string? + */ +Object.defineProperty(Setting.prototype, 'value', { + get: function() { + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + return imports.prefBranch.getBoolPref(this.name); + + case imports.prefBranch.PREF_INT: + return imports.prefBranch.getIntPref(this.name); + + case imports.prefBranch.PREF_STRING: + var value = imports.prefBranch.getComplexValue(this.name, + Ci.nsISupportsString).data; + // In case of a localized string + if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(value)) { + value = imports.prefBranch.getComplexValue(this.name, + Ci.nsIPrefLocalizedString).data; + } + return value; + + default: + throw new Error('Invalid value for ' + this.name); + } + }, + + set: function(value) { + if (imports.prefBranch.prefIsLocked(this.name)) { + throw new Error('Locked preference ' + this.name); + } + + switch (imports.prefBranch.getPrefType(this.name)) { + case imports.prefBranch.PREF_BOOL: + imports.prefBranch.setBoolPref(this.name, value); + break; + + case imports.prefBranch.PREF_INT: + imports.prefBranch.setIntPref(this.name, value); + break; + + case imports.prefBranch.PREF_STRING: + imports.supportsString.data = value; + imports.prefBranch.setComplexValue(this.name, + Ci.nsISupportsString, + imports.supportsString); + break; + + default: + throw new Error('Invalid value for ' + this.name); + } + + Services.prefs.savePrefFile(null); + }, + + enumerable: true +}); diff --git a/devtools/shared/gcli/source/lib/gcli/system.js b/devtools/shared/gcli/source/lib/gcli/system.js new file mode 100644 index 000000000..5a4719b8d --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/system.js @@ -0,0 +1,370 @@ +/* + * 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 Commands = require('./commands/commands').Commands; +var Connectors = require('./connectors/connectors').Connectors; +var Converters = require('./converters/converters').Converters; +var Fields = require('./fields/fields').Fields; +var Languages = require('./languages/languages').Languages; +var Settings = require('./settings').Settings; +var Types = require('./types/types').Types; + +/** + * This is the heart of the API that we expose to the outside. + * @param options Object that customizes how the system acts. Valid properties: + * - commands, connectors, converters, fields, languages, settings, types: + * Custom configured manager objects for these item types + * - location: a system with a location will ignore commands that don't have a + * matching runAt property. This is principly for client/server setups where + * we import commands from the server to the client, so a system with + * `{ location: 'client' }` will silently ignore commands with + * `{ runAt: 'server' }`. Any system without a location will accept commands + * with any runAt property (including none). + */ +exports.createSystem = function(options) { + options = options || {}; + var location = options.location; + + // The plural/singular thing may make you want to scream, but it allows us + // to say components[getItemType(item)], so a lookup here (and below) saves + // multiple lookups in the middle of the code + var components = { + connector: options.connectors || new Connectors(), + converter: options.converters || new Converters(), + field: options.fields || new Fields(), + language: options.languages || new Languages(), + type: options.types || new Types() + }; + components.setting = new Settings(components.type); + components.command = new Commands(components.type, location); + + var getItemType = function(item) { + if (item.item) { + return item.item; + } + // Some items are registered using the constructor so we need to check + // the prototype for the the type of the item + return (item.prototype && item.prototype.item) ? + item.prototype.item : 'command'; + }; + + var addItem = function(item) { + try { + components[getItemType(item)].add(item); + } + catch (ex) { + if (item != null) { + console.error('While adding: ' + item.name); + } + throw ex; + } + }; + + var removeItem = function(item) { + components[getItemType(item)].remove(item); + }; + + /** + * loadableModules is a lookup of names to module loader functions (like + * the venerable 'require') to which we can pass a name and get back a + * JS object (or a promise of a JS object). This allows us to have custom + * loaders to get stuff from the filesystem etc. + */ + var loadableModules = {}; + + /** + * loadedModules is a lookup by name of the things returned by the functions + * in loadableModules so we can track what we need to unload / reload. + */ + var loadedModules = {}; + + var unloadModule = function(name) { + var existingModule = loadedModules[name]; + if (existingModule != null) { + existingModule.items.forEach(removeItem); + } + delete loadedModules[name]; + }; + + var loadModule = function(name) { + var existingModule = loadedModules[name]; + unloadModule(name); + + // And load the new items + try { + var loader = loadableModules[name]; + return Promise.resolve(loader(name)).then(function(newModule) { + if (existingModule === newModule) { + return; + } + + if (newModule == null) { + throw 'Module \'' + name + '\' not found'; + } + + if (newModule.items == null || typeof newModule.items.forEach !== 'function') { + console.log('Exported properties: ' + Object.keys(newModule).join(', ')); + throw 'Module \'' + name + '\' has no \'items\' array export'; + } + + newModule.items.forEach(addItem); + + loadedModules[name] = newModule; + }); + } + catch (ex) { + console.error('Failed to load module ' + name + ': ' + ex); + console.error(ex.stack); + + return Promise.resolve(); + } + }; + + var pendingChanges = false; + + var system = { + addItems: function(items) { + items.forEach(addItem); + }, + + removeItems: function(items) { + items.forEach(removeItem); + }, + + addItemsByModule: function(names, options) { + var promises = []; + + options = options || {}; + if (!options.delayedLoad) { + // We could be about to add many commands, just report the change once + this.commands.onCommandsChange.holdFire(); + } + + if (typeof names === 'string') { + names = [ names ]; + } + names.forEach(function(name) { + if (options.loader == null) { + options.loader = function(name) { + return require(name); + }; + } + loadableModules[name] = options.loader; + + if (options.delayedLoad) { + pendingChanges = true; + } + else { + promises.push(loadModule(name).catch(console.error)); + } + }); + + if (options.delayedLoad) { + return Promise.resolve(); + } + else { + return Promise.all(promises).then(function() { + this.commands.onCommandsChange.resumeFire(); + }.bind(this)); + } + }, + + removeItemsByModule: function(name) { + this.commands.onCommandsChange.holdFire(); + + delete loadableModules[name]; + unloadModule(name); + + this.commands.onCommandsChange.resumeFire(); + }, + + load: function() { + if (!pendingChanges) { + return Promise.resolve(); + } + this.commands.onCommandsChange.holdFire(); + + // clone loadedModules, so we can remove what is left at the end + var modules = Object.keys(loadedModules).map(function(name) { + return loadedModules[name]; + }); + + var promises = Object.keys(loadableModules).map(function(name) { + delete modules[name]; + return loadModule(name).catch(console.error); + }); + + Object.keys(modules).forEach(unloadModule); + pendingChanges = false; + + return Promise.all(promises).then(function() { + this.commands.onCommandsChange.resumeFire(); + }.bind(this)); + }, + + destroy: function() { + this.commands.onCommandsChange.holdFire(); + + Object.keys(loadedModules).forEach(function(name) { + unloadModule(name); + }); + + this.commands.onCommandsChange.resumeFire(); + }, + + toString: function() { + return 'System [' + + 'commands:' + components.command.getAll().length + ', ' + + 'connectors:' + components.connector.getAll().length + ', ' + + 'converters:' + components.converter.getAll().length + ', ' + + 'fields:' + components.field.getAll().length + ', ' + + 'settings:' + components.setting.getAll().length + ', ' + + 'types:' + components.type.getTypeNames().length + ']'; + } + }; + + Object.defineProperty(system, 'commands', { + get: function() { return components.command; }, + enumerable: true + }); + + Object.defineProperty(system, 'connectors', { + get: function() { return components.connector; }, + enumerable: true + }); + + Object.defineProperty(system, 'converters', { + get: function() { return components.converter; }, + enumerable: true + }); + + Object.defineProperty(system, 'fields', { + get: function() { return components.field; }, + enumerable: true + }); + + Object.defineProperty(system, 'languages', { + get: function() { return components.language; }, + enumerable: true + }); + + Object.defineProperty(system, 'settings', { + get: function() { return components.setting; }, + enumerable: true + }); + + Object.defineProperty(system, 'types', { + get: function() { return components.type; }, + enumerable: true + }); + + return system; +}; + +/** + * Connect a local system with another at the other end of a connector + * @param system System to which we're adding commands + * @param front Front which allows access to the remote system from which we + * import commands + * @param customProps Array of strings specifying additional properties defined + * on remote commands that should be considered part of the metadata for the + * commands imported into the local system + */ +exports.connectFront = function(system, front, customProps) { + system._handleCommandsChanged = function() { + syncItems(system, front, customProps).catch(util.errorHandler); + }; + front.on('commands-changed', system._handleCommandsChanged); + + return syncItems(system, front, customProps); +}; + +/** + * Undo the effect of #connectFront + */ +exports.disconnectFront = function(system, front) { + front.off('commands-changed', system._handleCommandsChanged); + system._handleCommandsChanged = undefined; + removeItemsFromFront(system, front); +}; + +/** + * Remove the items in this system that came from a previous sync action, and + * re-add them. See connectFront() for explanation of properties + */ +function syncItems(system, front, customProps) { + return front.specs(customProps).then(function(specs) { + removeItemsFromFront(system, front); + + var remoteItems = addLocalFunctions(specs, front); + system.addItems(remoteItems); + + return system; + }); +}; + +/** + * Take the data from the 'specs' command (or the 'commands-changed' event) and + * add function to proxy the execution back over the front + */ +function addLocalFunctions(specs, front) { + // Inject an 'exec' function into the commands, and the front into + // all the remote types + specs.forEach(function(commandSpec) { + // HACK: Tack the front to the command so we know how to remove it + // in removeItemsFromFront() below + commandSpec.front = front; + + // Tell the type instances for a command how to contact their counterparts + // Don't confuse this with setting the front on the commandSpec which is + // about associating a proxied command with it's source for later removal. + // This is actually going to be used by the type + commandSpec.params.forEach(function(param) { + if (typeof param.type !== 'string') { + param.type.front = front; + } + }); + + if (!commandSpec.isParent) { + commandSpec.exec = function(args, context) { + var typed = (context.prefix ? context.prefix + ' ' : '') + context.typed; + return front.execute(typed).then(function(reply) { + var typedData = context.typedData(reply.type, reply.data); + return reply.isError ? Promise.reject(typedData) : typedData; + }); + }; + } + + commandSpec.isProxy = true; + }); + + return specs; +} + +/** + * Go through all the commands removing any that are associated with the + * given front. The method of association is the hack in addLocalFunctions. + */ +function removeItemsFromFront(system, front) { + system.commands.getAll().forEach(function(command) { + if (command.front === front) { + system.commands.remove(command); + } + }); +} diff --git a/devtools/shared/gcli/source/lib/gcli/types/array.js b/devtools/shared/gcli/source/lib/gcli/types/array.js new file mode 100644 index 000000000..381bd0b80 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/array.js @@ -0,0 +1,80 @@ +/* + * 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 ArrayConversion = require('./types').ArrayConversion; +var ArrayArgument = require('./types').ArrayArgument; + +exports.items = [ + { + // A set of objects of the same type + item: 'type', + name: 'array', + subtype: undefined, + + constructor: function() { + if (!this.subtype) { + console.error('Array.typeSpec is missing subtype. Assuming string.' + + this.name); + this.subtype = 'string'; + } + this.subtype = this.types.createType(this.subtype); + }, + + getSpec: function(commandName, paramName) { + return { + name: 'array', + subtype: this.subtype.getSpec(commandName, paramName), + }; + }, + + stringify: function(values, context) { + if (values == null) { + return ''; + } + // BUG 664204: Check for strings with spaces and add quotes + return values.join(' '); + }, + + parse: function(arg, context) { + if (arg.type !== 'ArrayArgument') { + console.error('non ArrayArgument to ArrayType.parse', arg); + throw new Error('non ArrayArgument to ArrayType.parse'); + } + + // Parse an argument to a conversion + // Hack alert. ArrayConversion needs to be able to answer questions about + // the status of individual conversions in addition to the overall state. + // |subArg.conversion| allows us to do that easily. + var subArgParse = function(subArg) { + return this.subtype.parse(subArg, context).then(function(conversion) { + subArg.conversion = conversion; + return conversion; + }.bind(this)); + }.bind(this); + + var conversionPromises = arg.getArguments().map(subArgParse); + return Promise.all(conversionPromises).then(function(conversions) { + return new ArrayConversion(conversions, arg); + }); + }, + + getBlank: function(context) { + return new ArrayConversion([], new ArrayArgument()); + } + }, +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/boolean.js b/devtools/shared/gcli/source/lib/gcli/types/boolean.js new file mode 100644 index 000000000..01f5f5022 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/boolean.js @@ -0,0 +1,62 @@ +/* + * 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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; +var SelectionType = require('./selection').SelectionType; + +exports.items = [ + { + // 'boolean' type + item: 'type', + name: 'boolean', + parent: 'selection', + + getSpec: function() { + return 'boolean'; + }, + + lookup: [ + { name: 'false', value: false }, + { name: 'true', value: true } + ], + + parse: function(arg, context) { + if (arg.type === 'TrueNamedArgument') { + return Promise.resolve(new Conversion(true, arg)); + } + if (arg.type === 'FalseNamedArgument') { + return Promise.resolve(new Conversion(false, arg)); + } + return SelectionType.prototype.parse.call(this, arg, context); + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return '' + value; + }, + + getBlank: function(context) { + return new Conversion(false, new BlankArgument(), Status.VALID, '', + Promise.resolve(this.lookup)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/command.js b/devtools/shared/gcli/source/lib/gcli/types/command.js new file mode 100644 index 000000000..779aa77ab --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/command.js @@ -0,0 +1,255 @@ +/* + * 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 l10n = require('../util/l10n'); +var spell = require('../util/spell'); +var SelectionType = require('./selection').SelectionType; +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var cli = require('../cli'); + +exports.items = [ + { + // Select from the available parameters to a command + item: 'type', + name: 'param', + parent: 'selection', + stringifyProperty: 'name', + requisition: undefined, + isIncompleteName: undefined, + + getSpec: function() { + throw new Error('param type is not remotable'); + }, + + lookup: function() { + return exports.getDisplayedParamLookup(this.requisition); + }, + + parse: function(arg, context) { + if (this.isIncompleteName) { + return SelectionType.prototype.parse.call(this, arg, context); + } + else { + var message = l10n.lookup('cliUnusedArg'); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, message)); + } + } + }, + { + // Select from the available commands + // This is very similar to a SelectionType, however the level of hackery in + // SelectionType to make it handle Commands correctly was to high, so we + // simplified. + // If you are making changes to this code, you should check there too. + item: 'type', + name: 'command', + parent: 'selection', + stringifyProperty: 'name', + allowNonExec: true, + + getSpec: function() { + return { + name: 'command', + allowNonExec: this.allowNonExec + }; + }, + + lookup: function(context) { + var commands = cli.getMapping(context).requisition.system.commands; + return exports.getCommandLookup(commands); + }, + + parse: function(arg, context) { + var conversion = exports.parse(context, arg, this.allowNonExec); + return Promise.resolve(conversion); + } + } +]; + +exports.getDisplayedParamLookup = function(requisition) { + var displayedParams = []; + var command = requisition.commandAssignment.value; + if (command != null) { + command.params.forEach(function(param) { + var arg = requisition.getAssignment(param.name).arg; + if (!param.isPositionalAllowed && arg.type === 'BlankArgument') { + displayedParams.push({ name: '--' + param.name, value: param }); + } + }); + } + return displayedParams; +}; + +exports.parse = function(context, arg, allowNonExec) { + var commands = cli.getMapping(context).requisition.system.commands; + var lookup = exports.getCommandLookup(commands); + var predictions = exports.findPredictions(arg, lookup); + return exports.convertPredictions(commands, arg, allowNonExec, predictions); +}; + +exports.getCommandLookup = function(commands) { + var sorted = commands.getAll().sort(function(c1, c2) { + return c1.name.localeCompare(c2.name); + }); + return sorted.map(function(command) { + return { name: command.name, value: command }; + }); +}; + +exports.findPredictions = function(arg, lookup) { + var predictions = []; + var i, option; + var maxPredictions = Conversion.maxPredictions; + var match = arg.text.toLowerCase(); + + // Add an option to our list of predicted options + var addToPredictions = function(option) { + if (arg.text.length === 0) { + // If someone hasn't typed anything, we only show top level commands in + // the menu. i.e. sub-commands (those with a space in their name) are + // excluded. We do this to keep the list at an overview level. + if (option.name.indexOf(' ') === -1) { + predictions.push(option); + } + } + else { + // If someone has typed something, then we exclude parent commands + // (those without an exec). We do this because the user is drilling + // down and doesn't need the summary level. + if (option.value.exec != null) { + predictions.push(option); + } + } + }; + + // If the arg has a suffix then we're kind of 'done'. Only an exact + // match will do. + if (arg.suffix.match(/ +/)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text || + option.name.indexOf(arg.text + ' ') === 0) { + addToPredictions(option); + } + } + + return predictions; + } + + // Cache lower case versions of all the option names + for (i = 0; i < lookup.length; i++) { + option = lookup[i]; + if (option._gcliLowerName == null) { + option._gcliLowerName = option.name.toLowerCase(); + } + } + + // Exact hidden matches. If 'hidden: true' then we only allow exact matches + // All the tests after here check that !option.value.hidden + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + addToPredictions(option); + } + } + + // Start with prefix matching + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) === 0 && !option.value.hidden) { + if (predictions.indexOf(option) === -1) { + addToPredictions(option); + } + } + } + + // Try infix matching if we get less half max matched + if (predictions.length < (maxPredictions / 2)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) !== -1 && !option.value.hidden) { + if (predictions.indexOf(option) === -1) { + addToPredictions(option); + } + } + } + } + + // Try fuzzy matching if we don't get a prefix match + if (predictions.length === 0) { + var names = []; + lookup.forEach(function(opt) { + if (!opt.value.hidden) { + names.push(opt.name); + } + }); + var corrected = spell.correct(match, names); + if (corrected) { + lookup.forEach(function(opt) { + if (opt.name === corrected) { + predictions.push(opt); + } + }); + } + } + + return predictions; +}; + +exports.convertPredictions = function(commands, arg, allowNonExec, predictions) { + var command = commands.get(arg.text); + // Helper function - Commands like 'context' work best with parent + // commands which are not executable. However obviously to execute a + // command, it needs an exec function. + var execWhereNeeded = (allowNonExec || + (command != null && typeof command.exec === 'function')); + + var isExact = command && command.name === arg.text && + execWhereNeeded && predictions.length === 1; + var alternatives = isExact ? [] : predictions; + + if (command) { + var status = execWhereNeeded ? Status.VALID : Status.INCOMPLETE; + return new Conversion(command, arg, status, '', alternatives); + } + + if (predictions.length === 0) { + var msg = l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(undefined, arg, Status.ERROR, msg, alternatives); + } + + command = predictions[0].value; + + if (predictions.length === 1) { + // Is it an exact match of an executable command, + // or just the only possibility? + if (command.name === arg.text && execWhereNeeded) { + return new Conversion(command, arg, Status.VALID, ''); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', alternatives); + } + + // It's valid if the text matches, even if there are several options + if (predictions[0].name === arg.text) { + return new Conversion(command, arg, Status.VALID, '', alternatives); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', alternatives); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/types/date.js b/devtools/shared/gcli/source/lib/gcli/types/date.js new file mode 100644 index 000000000..f05569724 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/date.js @@ -0,0 +1,248 @@ +/* + * 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 l10n = require('../util/l10n'); +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +/** + * Helper for stringify() to left pad a single digit number with a single '0' + * so 1 -> '01', 42 -> '42', etc. + */ +function pad(number) { + var r = String(number); + return r.length === 1 ? '0' + r : r; +} + +/** + * Utility to convert a string to a date, throwing if the date can't be + * parsed rather than having an invalid date + */ +function toDate(str) { + var millis = Date.parse(str); + if (isNaN(millis)) { + throw new Error(l10n.lookupFormat('typesDateNan', [ str ])); + } + return new Date(millis); +} + +/** + * Is |thing| a valid date? + * @see http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript + */ +function isDate(thing) { + return Object.prototype.toString.call(thing) === '[object Date]' + && !isNaN(thing.getTime()); +} + +exports.items = [ + { + // ECMA 5.1 §15.9.1.1 + // @see http://stackoverflow.com/questions/11526504/minimum-and-maximum-date + item: 'type', + name: 'date', + step: 1, + min: new Date(-8640000000000000), + max: new Date(8640000000000000), + + constructor: function() { + this._origMin = this.min; + if (this.min != null) { + if (typeof this.min === 'string') { + this.min = toDate(this.min); + } + else if (isDate(this.min) || typeof this.min === 'function') { + this.min = this.min; + } + else { + throw new Error('date min value must be one of string/date/function'); + } + } + + this._origMax = this.max; + if (this.max != null) { + if (typeof this.max === 'string') { + this.max = toDate(this.max); + } + else if (isDate(this.max) || typeof this.max === 'function') { + this.max = this.max; + } + else { + throw new Error('date max value must be one of string/date/function'); + } + } + }, + + getSpec: function() { + var spec = { + name: 'date' + }; + if (this.step !== 1) { + spec.step = this.step; + } + if (this._origMax != null) { + spec.max = this._origMax; + } + if (this._origMin != null) { + spec.min = this._origMin; + } + return spec; + }, + + stringify: function(value, context) { + if (!isDate(value)) { + return ''; + } + + var str = pad(value.getFullYear()) + '-' + + pad(value.getMonth() + 1) + '-' + + pad(value.getDate()); + + // Only add in the time if it's not midnight + if (value.getHours() !== 0 || value.getMinutes() !== 0 || + value.getSeconds() !== 0 || value.getMilliseconds() !== 0) { + + // What string should we use to separate the date from the time? + // There are 3 options: + // 'T': This is the standard from ISO8601. i.e. 2013-05-20T11:05 + // The good news - it's a standard. The bad news - it's weird and + // alien to many if not most users + // ' ': This looks nicest, but needs escaping (which GCLI will do + // automatically) so it would look like: '2013-05-20 11:05' + // Good news: looks best, bad news: on completion we place the + // cursor after the final ', breaking repeated increment/decrement + // '\ ': It's possible that we could find a way to use a \ to escape + // the space, so the output would look like: 2013-05-20\ 11:05 + // This would involve changes to a number of parts, and is + // probably too complex a solution for this problem for now + // In the short term I'm going for ' ', and raising the priority of + // cursor positioning on actions like increment/decrement/tab. + + str += ' ' + pad(value.getHours()); + str += ':' + pad(value.getMinutes()); + + // Only add in seconds/milliseconds if there is anything to report + if (value.getSeconds() !== 0 || value.getMilliseconds() !== 0) { + str += ':' + pad(value.getSeconds()); + if (value.getMilliseconds() !== 0) { + var milliVal = (value.getUTCMilliseconds() / 1000).toFixed(3); + str += '.' + String(milliVal).slice(2, 5); + } + } + } + + return str; + }, + + getMax: function(context) { + if (typeof this.max === 'function') { + return this._max(context); + } + if (isDate(this.max)) { + return this.max; + } + return undefined; + }, + + getMin: function(context) { + if (typeof this.min === 'function') { + return this._min(context); + } + if (isDate(this.min)) { + return this.min; + } + return undefined; + }, + + parse: function(arg, context) { + var value; + + if (arg.text.replace(/\s/g, '').length === 0) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + // Lots of room for improvement here: 1h ago, in two days, etc. + // Should "1h ago" dynamically update the step? + if (arg.text.toLowerCase() === 'now' || + arg.text.toLowerCase() === 'today') { + value = new Date(); + } + else if (arg.text.toLowerCase() === 'yesterday') { + value = new Date(); + value.setDate(value.getDate() - 1); + } + else if (arg.text.toLowerCase() === 'tomorrow') { + value = new Date(); + value.setDate(value.getDate() + 1); + } + else { + // So now actual date parsing. + // Javascript dates are a mess. Like the default date libraries in most + // common languages, but with added browser weirdness. + // There is an argument for saying that the user will expect dates to + // be formatted as JavaScript dates, except that JS dates are of + // themselves very unexpected. + // See http://blog.dygraphs.com/2012/03/javascript-and-dates-what-mess.html + + // The timezone used by Date.parse depends on whether or not the string + // can be interpreted as ISO-8601, so "2000-01-01" is not the same as + // "2000/01/01" (unless your TZ aligns with UTC) because the first is + // ISO-8601 and therefore assumed to be UTC, where the latter is + // assumed to be in the local timezone. + + // First, if the user explicitly includes a 'Z' timezone marker, then + // we assume they know what they are doing with timezones. ISO-8601 + // uses 'Z' as a marker for 'Zulu time', zero hours offset i.e. UTC + if (arg.text.indexOf('Z') !== -1) { + value = new Date(arg.text); + } + else { + // Now we don't want the browser to assume ISO-8601 and therefore use + // UTC so we replace the '-' with '/' + value = new Date(arg.text.replace(/-/g, '/')); + } + + if (isNaN(value.getTime())) { + var msg = l10n.lookupFormat('typesDateNan', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + } + + return Promise.resolve(new Conversion(value, arg)); + }, + + nudge: function(value, by, context) { + if (!isDate(value)) { + return new Date(); + } + + var newValue = new Date(value); + newValue.setDate(value.getDate() + (by * this.step)); + + if (newValue < this.getMin(context)) { + return this.getMin(context); + } + else if (newValue > this.getMax(context)) { + return this.getMax(); + } + else { + return newValue; + } + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/delegate.js b/devtools/shared/gcli/source/lib/gcli/types/delegate.js new file mode 100644 index 000000000..978718231 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/delegate.js @@ -0,0 +1,158 @@ +/* + * 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 Conversion = require('./types').Conversion; +var Status = require('./types').Status; +var BlankArgument = require('./types').BlankArgument; + +/** + * The types we expose for registration + */ +exports.items = [ + // A type for "we don't know right now, but hope to soon" + { + item: 'type', + name: 'delegate', + + getSpec: function(commandName, paramName) { + return { + name: 'delegate', + param: paramName + }; + }, + + // Child types should implement this method to return an instance of the type + // that should be used. If no type is available, or some sort of temporary + // placeholder is required, BlankType can be used. + delegateType: undefined, + + stringify: function(value, context) { + return this.getType(context).then(function(delegated) { + return delegated.stringify(value, context); + }.bind(this)); + }, + + parse: function(arg, context) { + return this.getType(context).then(function(delegated) { + return delegated.parse(arg, context); + }.bind(this)); + }, + + nudge: function(value, by, context) { + return this.getType(context).then(function(delegated) { + return delegated.nudge ? + delegated.nudge(value, by, context) : + undefined; + }.bind(this)); + }, + + getType: function(context) { + if (this.delegateType === undefined) { + return Promise.resolve(this.types.createType('blank')); + } + + var type = this.delegateType(context); + if (typeof type.parse !== 'function') { + type = this.types.createType(type); + } + return Promise.resolve(type); + }, + + // DelegateType is designed to be inherited from, so DelegateField needs a + // way to check if something works like a delegate without using 'name' + isDelegate: true, + + // Technically we perhaps should proxy this, except that properties are + // inherently synchronous, so we can't. It doesn't seem important enough to + // change the function definition to accommodate this right now + isImportant: false + }, + { + item: 'type', + name: 'remote', + paramName: undefined, + blankIsValid: false, + hasPredictions: true, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName, + blankIsValid: this.blankIsValid + }; + }, + + getBlank: function(context) { + if (this.blankIsValid) { + return new Conversion({ stringified: '' }, + new BlankArgument(), Status.VALID); + } + else { + return new Conversion(undefined, new BlankArgument(), + Status.INCOMPLETE, ''); + } + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + // remote types are client only, and we don't attempt to transfer value + // objects to the client (we can't be sure the are jsonable) so it is a + // bit strange to be asked to stringify a value object, however since + // parse creates a Conversion with a (fake) value object we might be + // asked to stringify that. We can stringify fake value objects. + if (typeof value.stringified === 'string') { + return value.stringified; + } + throw new Error('Can\'t stringify that value'); + }, + + parse: function(arg, context) { + return this.front.parseType(context.typed, this.paramName).then(function(json) { + var status = Status.fromString(json.status); + return new Conversion(undefined, arg, status, json.message, json.predictions); + }.bind(this)); + }, + + nudge: function(value, by, context) { + return this.front.nudgeType(context.typed, by, this.paramName).then(function(json) { + return { stringified: json.arg }; + }.bind(this)); + } + }, + // 'blank' is a type for use with DelegateType when we don't know yet. + // It should not be used anywhere else. + { + item: 'type', + name: 'blank', + + getSpec: function(commandName, paramName) { + return 'blank'; + }, + + stringify: function(value, context) { + return ''; + }, + + parse: function(arg, context) { + return Promise.resolve(new Conversion(undefined, arg)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/file.js b/devtools/shared/gcli/source/lib/gcli/types/file.js new file mode 100644 index 000000000..004f0108c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/file.js @@ -0,0 +1,96 @@ +/* + * 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'; + +/* + * The file type is a bit of a spiders-web, but there isn't a nice solution + * yet. The core of the problem is that the modules used by Firefox and NodeJS + * intersect with the modules used by the web, but not each other. Except here. + * So we have to do something fancy to get the sharing but not mess up the web. + * + * This file requires 'gcli/types/fileparser', and there are 4 implementations + * of this: + * - '/lib/gcli/types/fileparser.js', the default web version that uses XHR to + * talk to the node server + * - '/lib/server/gcli/types/fileparser.js', an NodeJS stub, and ... + * - '/mozilla/gcli/types/fileparser.js', the Firefox implementation both of + * these are shims which import + * - 'gcli/util/fileparser', does the real work, except the actual file access + * + * The file access comes from the 'gcli/util/filesystem' module, and there are + * 2 implementations of this: + * - '/lib/server/gcli/util/filesystem.js', which uses NodeJS APIs + * - '/mozilla/gcli/util/filesystem.js', which uses OS.File APIs + */ + +var fileparser = require('./fileparser'); +var Conversion = require('./types').Conversion; + +exports.items = [ + { + item: 'type', + name: 'file', + + filetype: 'any', // One of 'file', 'directory', 'any' + existing: 'maybe', // Should be one of 'yes', 'no', 'maybe' + matches: undefined, // RegExp to match the file part of the path + + hasPredictions: true, + + constructor: function() { + if (this.filetype !== 'any' && this.filetype !== 'file' && + this.filetype !== 'directory') { + throw new Error('filetype must be one of [any|file|directory]'); + } + + if (this.existing !== 'yes' && this.existing !== 'no' && + this.existing !== 'maybe') { + throw new Error('existing must be one of [yes|no|maybe]'); + } + }, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName + }; + }, + + stringify: function(file) { + if (file == null) { + return ''; + } + + return file.toString(); + }, + + parse: function(arg, context) { + var options = { + filetype: this.filetype, + existing: this.existing, + matches: this.matches + }; + var promise = fileparser.parse(context, arg.text, options); + + return promise.then(function(reply) { + return new Conversion(reply.value, arg, reply.status, + reply.message, reply.predictor); + }); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/fileparser.js b/devtools/shared/gcli/source/lib/gcli/types/fileparser.js new file mode 100644 index 000000000..5db86dc66 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/fileparser.js @@ -0,0 +1,19 @@ +/* + * 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'; + +exports.parse = require('../util/fileparser').parse; diff --git a/devtools/shared/gcli/source/lib/gcli/types/javascript.js b/devtools/shared/gcli/source/lib/gcli/types/javascript.js new file mode 100644 index 000000000..71324ef2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/javascript.js @@ -0,0 +1,522 @@ +/* + * 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 l10n = require('../util/l10n'); + +var Conversion = require('./types').Conversion; +var Type = require('./types').Type; +var Status = require('./types').Status; + +/** + * 'javascript' handles scripted input + */ +function JavascriptType(typeSpec) { +} + +JavascriptType.prototype = Object.create(Type.prototype); + +JavascriptType.prototype.getSpec = function(commandName, paramName) { + return { + name: 'remote', + paramName: paramName + }; +}; + +JavascriptType.prototype.stringify = function(value, context) { + if (value == null) { + return ''; + } + return value; +}; + +/** + * When sorting out completions, there is no point in displaying millions of + * matches - this the number of matches that we aim for + */ +JavascriptType.MAX_COMPLETION_MATCHES = 10; + +JavascriptType.prototype.parse = function(arg, context) { + var typed = arg.text; + var scope = (context.environment.window == null) ? + null : context.environment.window; + + // No input is undefined + if (typed === '') { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE)); + } + // Just accept numbers + if (!isNaN(parseFloat(typed)) && isFinite(typed)) { + return Promise.resolve(new Conversion(typed, arg)); + } + // Just accept constants like true/false/null/etc + if (typed.trim().match(/(null|undefined|NaN|Infinity|true|false)/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // Analyze the input text and find the beginning of the last part that + // should be completed. + var beginning = this._findCompletionBeginning(typed); + + // There was an error analyzing the string. + if (beginning.err) { + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, beginning.err)); + } + + // If the current state is ParseState.COMPLEX, then we can't do completion. + // so bail out now + if (beginning.state === ParseState.COMPLEX) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // If the current state is not ParseState.NORMAL, then we are inside of a + // string which means that no completion is possible. + if (beginning.state !== ParseState.NORMAL) { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + var completionPart = typed.substring(beginning.startPos); + var properties = completionPart.split('.'); + var matchProp; + var prop; + + if (properties.length > 1) { + matchProp = properties.pop().trimLeft(); + for (var i = 0; i < properties.length; i++) { + prop = properties[i].trim(); + + // We can't complete on null.foo, so bail out + if (scope == null) { + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, + l10n.lookup('jstypeParseScope'))); + } + + if (prop === '') { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + // Check if prop is a getter function on 'scope'. Functions can change + // other stuff so we can't execute them to get the next object. Stop here. + if (this._isSafeProperty(scope, prop)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + try { + scope = scope[prop]; + } + catch (ex) { + // It would be nice to be able to report this error in some way but + // as it can happen just when someone types '{sessionStorage.', it + // almost doesn't really count as an error, so we ignore it + return Promise.resolve(new Conversion(typed, arg, Status.VALID, '')); + } + } + } + else { + matchProp = properties[0].trimLeft(); + } + + // If the reason we just stopped adjusting the scope was a non-simple string, + // then we're not sure if the input is valid or invalid, so accept it + if (prop && !prop.match(/^[0-9A-Za-z]*$/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // However if the prop was a simple string, it is an error + if (scope == null) { + var msg = l10n.lookupFormat('jstypeParseMissing', [ prop ]); + return Promise.resolve(new Conversion(typed, arg, Status.ERROR, msg)); + } + + // If the thing we're looking for isn't a simple string, then we're not going + // to find it, but we're not sure if it's valid or invalid, so accept it + if (!matchProp.match(/^[0-9A-Za-z]*$/)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + // Skip Iterators and Generators. + if (this._isIteratorOrGenerator(scope)) { + return Promise.resolve(new Conversion(typed, arg)); + } + + var matchLen = matchProp.length; + var prefix = matchLen === 0 ? typed : typed.slice(0, -matchLen); + var status = Status.INCOMPLETE; + var message = ''; + + // We really want an array of matches (for sorting) but it's easier to + // detect existing members if we're using a map initially + var matches = {}; + + // We only display a maximum of MAX_COMPLETION_MATCHES, so there is no point + // in digging up the prototype chain for matches that we're never going to + // use. Initially look for matches directly on the object itself and then + // look up the chain to find more + var distUpPrototypeChain = 0; + var root = scope; + try { + while (root != null && + Object.keys(matches).length < JavascriptType.MAX_COMPLETION_MATCHES) { + + /* jshint loopfunc:true */ + Object.keys(root).forEach(function(property) { + // Only add matching properties. Also, as we're walking up the + // prototype chain, properties on 'higher' prototypes don't override + // similarly named properties lower down + if (property.indexOf(matchProp) === 0 && !(property in matches)) { + matches[property] = { + prop: property, + distUpPrototypeChain: distUpPrototypeChain + }; + } + }); + + distUpPrototypeChain++; + root = Object.getPrototypeOf(root); + } + } + catch (ex) { + return Promise.resolve(new Conversion(typed, arg, Status.INCOMPLETE, '')); + } + + // Convert to an array for sorting, and while we're at it, note if we got + // an exact match so we know that this input is valid + matches = Object.keys(matches).map(function(property) { + if (property === matchProp) { + status = Status.VALID; + } + return matches[property]; + }); + + // The sort keys are: + // - Being on the object itself, not in the prototype chain + // - The lack of existence of a vendor prefix + // - The name + matches.sort(function(m1, m2) { + if (m1.distUpPrototypeChain !== m2.distUpPrototypeChain) { + return m1.distUpPrototypeChain - m2.distUpPrototypeChain; + } + // Push all vendor prefixes to the bottom of the list + return isVendorPrefixed(m1.prop) ? + (isVendorPrefixed(m2.prop) ? m1.prop.localeCompare(m2.prop) : 1) : + (isVendorPrefixed(m2.prop) ? -1 : m1.prop.localeCompare(m2.prop)); + }); + + // Trim to size. There is a bug for doing a better job of finding matches + // (bug 682694), but in the mean time there is a performance problem + // associated with creating a large number of DOM nodes that few people will + // ever read, so trim ... + if (matches.length > JavascriptType.MAX_COMPLETION_MATCHES) { + matches = matches.slice(0, JavascriptType.MAX_COMPLETION_MATCHES - 1); + } + + // Decorate the matches with: + // - a description + // - a value (for the menu) and, + // - an incomplete flag which reports if we should assume that the user isn't + // going to carry on the JS expression with this input so far + var predictions = matches.map(function(match) { + var description; + var incomplete = true; + + if (this._isSafeProperty(scope, match.prop)) { + description = '(property getter)'; + } + else { + try { + var value = scope[match.prop]; + + if (typeof value === 'function') { + description = '(function)'; + } + else if (typeof value === 'boolean' || typeof value === 'number') { + description = '= ' + value; + incomplete = false; + } + else if (typeof value === 'string') { + if (value.length > 40) { + value = value.substring(0, 37) + '…'; + } + description = '= \'' + value + '\''; + incomplete = false; + } + else { + description = '(' + typeof value + ')'; + } + } + catch (ex) { + description = '(' + l10n.lookup('jstypeParseError') + ')'; + } + } + + return { + name: prefix + match.prop, + value: { + name: prefix + match.prop, + description: description + }, + description: description, + incomplete: incomplete + }; + }, this); + + if (predictions.length === 0) { + status = Status.ERROR; + message = l10n.lookupFormat('jstypeParseMissing', [ matchProp ]); + } + + // If the match is the only one possible, and its VALID, predict nothing + if (predictions.length === 1 && status === Status.VALID) { + predictions = []; + } + + return Promise.resolve(new Conversion(typed, arg, status, message, + Promise.resolve(predictions))); +}; + +/** + * Does the given property have a prefix that indicates that it is vendor + * specific? + */ +function isVendorPrefixed(name) { + return name.indexOf('moz') === 0 || + name.indexOf('webkit') === 0 || + name.indexOf('ms') === 0; +} + +/** + * Constants used in return value of _findCompletionBeginning() + */ +var ParseState = { + /** + * We have simple input like window.foo, without any punctuation that makes + * completion prediction be confusing or wrong + */ + NORMAL: 0, + + /** + * The cursor is in some Javascript that makes completion hard to predict, + * like console.log( + */ + COMPLEX: 1, + + /** + * The cursor is inside single quotes (') + */ + QUOTE: 2, + + /** + * The cursor is inside single quotes (") + */ + DQUOTE: 3 +}; + +var OPEN_BODY = '{[('.split(''); +var CLOSE_BODY = '}])'.split(''); +var OPEN_CLOSE_BODY = { + '{': '}', + '[': ']', + '(': ')' +}; + +/** + * How we distinguish between simple and complex JS input. We attempt + * completion against simple JS. + */ +var simpleChars = /[a-zA-Z0-9.]/; + +/** + * Analyzes a given string to find the last statement that is interesting for + * later completion. + * @param text A string to analyze + * @return If there was an error in the string detected, then a object like + * { err: 'ErrorMesssage' } + * is returned, otherwise a object like + * { + * state: ParseState.NORMAL|ParseState.QUOTE|ParseState.DQUOTE, + * startPos: index of where the last statement begins + * } + */ +JavascriptType.prototype._findCompletionBeginning = function(text) { + var bodyStack = []; + + var state = ParseState.NORMAL; + var start = 0; + var c; + var complex = false; + + for (var i = 0; i < text.length; i++) { + c = text[i]; + if (!simpleChars.test(c)) { + complex = true; + } + + switch (state) { + // Normal JS state. + case ParseState.NORMAL: + if (c === '"') { + state = ParseState.DQUOTE; + } + else if (c === '\'') { + state = ParseState.QUOTE; + } + else if (c === ';') { + start = i + 1; + } + else if (c === ' ') { + start = i + 1; + } + else if (OPEN_BODY.indexOf(c) != -1) { + bodyStack.push({ + token: c, + start: start + }); + start = i + 1; + } + else if (CLOSE_BODY.indexOf(c) != -1) { + var last = bodyStack.pop(); + if (!last || OPEN_CLOSE_BODY[last.token] != c) { + return { err: l10n.lookup('jstypeBeginSyntax') }; + } + if (c === '}') { + start = i + 1; + } + else { + start = last.start; + } + } + break; + + // Double quote state > " < + case ParseState.DQUOTE: + if (c === '\\') { + i ++; + } + else if (c === '\n') { + return { err: l10n.lookup('jstypeBeginUnterm') }; + } + else if (c === '"') { + state = ParseState.NORMAL; + } + break; + + // Single quote state > ' < + case ParseState.QUOTE: + if (c === '\\') { + i ++; + } + else if (c === '\n') { + return { err: l10n.lookup('jstypeBeginUnterm') }; + } + else if (c === '\'') { + state = ParseState.NORMAL; + } + break; + } + } + + if (state === ParseState.NORMAL && complex) { + state = ParseState.COMPLEX; + } + + return { + state: state, + startPos: start + }; +}; + +/** + * Return true if the passed object is either an iterator or a generator, and + * false otherwise + * @param obj The object to check + */ +JavascriptType.prototype._isIteratorOrGenerator = function(obj) { + if (obj === null) { + return false; + } + + if (typeof aObject === 'object') { + if (typeof obj.__iterator__ === 'function' || + obj.constructor && obj.constructor.name === 'Iterator') { + return true; + } + + try { + var str = obj.toString(); + if (typeof obj.next === 'function' && + str.indexOf('[object Generator') === 0) { + return true; + } + } + catch (ex) { + // window.history.next throws in the typeof check above. + return false; + } + } + + return false; +}; + +/** + * Would calling 'scope[prop]' cause the invocation of a non-native (i.e. user + * defined) function property? + * Since calling functions can have side effects, it's only safe to do that if + * explicitly requested, rather than because we're trying things out for the + * purposes of completion. + */ +JavascriptType.prototype._isSafeProperty = function(scope, prop) { + if (typeof scope !== 'object') { + return false; + } + + // Walk up the prototype chain of 'scope' looking for a property descriptor + // for 'prop' + var propDesc; + while (scope) { + try { + propDesc = Object.getOwnPropertyDescriptor(scope, prop); + if (propDesc) { + break; + } + } + catch (ex) { + // Native getters throw here. See bug 520882. + if (ex.name === 'NS_ERROR_XPC_BAD_CONVERT_JS' || + ex.name === 'NS_ERROR_XPC_BAD_OP_ON_WN_PROTO') { + return false; + } + return true; + } + scope = Object.getPrototypeOf(scope); + } + + if (!propDesc) { + return false; + } + + if (!propDesc.get) { + return false; + } + + // The property is safe if 'get' isn't a function or if the function has a + // prototype (in which case it's native) + return typeof propDesc.get !== 'function' || 'prototype' in propDesc.get; +}; + +JavascriptType.prototype.name = 'javascript'; + +exports.items = [ JavascriptType ]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/moz.build b/devtools/shared/gcli/source/lib/gcli/types/moz.build new file mode 100644 index 000000000..dc3063594 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/moz.build @@ -0,0 +1,25 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'array.js', + 'boolean.js', + 'command.js', + 'date.js', + 'delegate.js', + 'file.js', + 'fileparser.js', + 'javascript.js', + 'node.js', + 'number.js', + 'resource.js', + 'selection.js', + 'setting.js', + 'string.js', + 'types.js', + 'union.js', + 'url.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/types/node.js b/devtools/shared/gcli/source/lib/gcli/types/node.js new file mode 100644 index 000000000..2f71704e3 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/node.js @@ -0,0 +1,201 @@ +/* + * 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 Highlighter = require('../util/host').Highlighter; +var l10n = require('../util/l10n'); +var util = require('../util/util'); +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; + +/** + * Helper functions to be attached to the prototypes of NodeType and + * NodeListType to allow terminal to tell us which nodes should be highlighted + */ +function onEnter(assignment) { + // TODO: GCLI doesn't support passing a context to notifications of cursor + // position, so onEnter/onLeave/onChange are disabled below until we fix this + assignment.highlighter = new Highlighter(context.environment.window.document); + assignment.highlighter.nodelist = assignment.conversion.matches; +} + +/** @see #onEnter() */ +function onLeave(assignment) { + if (!assignment.highlighter) { + return; + } + + assignment.highlighter.destroy(); + delete assignment.highlighter; +} +/** @see #onEnter() */ +function onChange(assignment) { + if (assignment.conversion.matches == null) { + return; + } + if (!assignment.highlighter) { + return; + } + + assignment.highlighter.nodelist = assignment.conversion.matches; +} + +/** + * The exported 'node' and 'nodelist' types + */ +exports.items = [ + { + // The 'node' type is a CSS expression that refers to a single node + item: 'type', + name: 'node', + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName + }; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.__gcliQuery || 'Error'; + }, + + parse: function(arg, context) { + var reply; + + if (arg.text === '') { + reply = new Conversion(undefined, arg, Status.INCOMPLETE); + } + else { + var nodes; + try { + nodes = context.environment.window.document.querySelectorAll(arg.text); + if (nodes.length === 0) { + reply = new Conversion(undefined, arg, Status.INCOMPLETE, + l10n.lookup('nodeParseNone')); + } + else if (nodes.length === 1) { + var node = nodes.item(0); + node.__gcliQuery = arg.text; + + reply = new Conversion(node, arg, Status.VALID, ''); + } + else { + var msg = l10n.lookupFormat('nodeParseMultiple', [ nodes.length ]); + reply = new Conversion(undefined, arg, Status.ERROR, msg); + } + + reply.matches = nodes; + } + catch (ex) { + reply = new Conversion(undefined, arg, Status.ERROR, + l10n.lookup('nodeParseSyntax')); + } + } + + return Promise.resolve(reply); + }, + + // onEnter: onEnter, + // onLeave: onLeave, + // onChange: onChange + }, + { + // The 'nodelist' type is a CSS expression that refers to a node list + item: 'type', + name: 'nodelist', + + // The 'allowEmpty' option ensures that we do not complain if the entered + // CSS selector is valid, but does not match any nodes. There is some + // overlap between this option and 'defaultValue'. What the user wants, in + // most cases, would be to use 'defaultText' (i.e. what is typed rather than + // the value that it represents). However this isn't a concept that exists + // yet and should probably be a part of GCLI if/when it does. + // All NodeListTypes have an automatic defaultValue of an empty NodeList so + // they can easily be used in named parameters. + allowEmpty: false, + + constructor: function() { + if (typeof this.allowEmpty !== 'boolean') { + throw new Error('Legal values for allowEmpty are [true|false]'); + } + }, + + getSpec: function(commandName, paramName) { + return { + name: 'remote', + commandName: commandName, + paramName: paramName, + blankIsValid: true + }; + }, + + getBlank: function(context) { + var emptyNodeList = []; + if (context != null && context.environment.window != null) { + var doc = context.environment.window.document; + emptyNodeList = util.createEmptyNodeList(doc); + } + return new Conversion(emptyNodeList, new BlankArgument(), Status.VALID); + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.__gcliQuery || 'Error'; + }, + + parse: function(arg, context) { + var reply; + try { + if (arg.text === '') { + reply = new Conversion(undefined, arg, Status.INCOMPLETE); + } + else { + var nodes = context.environment.window.document.querySelectorAll(arg.text); + + if (nodes.length === 0 && !this.allowEmpty) { + reply = new Conversion(undefined, arg, Status.INCOMPLETE, + l10n.lookup('nodeParseNone')); + } + else { + nodes.__gcliQuery = arg.text; + reply = new Conversion(nodes, arg, Status.VALID, ''); + } + + reply.matches = nodes; + } + } + catch (ex) { + reply = new Conversion(undefined, arg, Status.ERROR, + l10n.lookup('nodeParseSyntax')); + } + + return Promise.resolve(reply); + }, + + // onEnter: onEnter, + // onLeave: onLeave, + // onChange: onChange + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/number.js b/devtools/shared/gcli/source/lib/gcli/types/number.js new file mode 100644 index 000000000..4c67e5807 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/number.js @@ -0,0 +1,181 @@ +/* + * 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 l10n = require('../util/l10n'); +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + // 'number' type + // Has custom max / min / step values to control increment and decrement + // and a boolean allowFloat property to clamp values to integers + item: 'type', + name: 'number', + + allowFloat: false, + max: undefined, + min: undefined, + step: 1, + + constructor: function() { + if (!this.allowFloat && + (this._isFloat(this.min) || + this._isFloat(this.max) || + this._isFloat(this.step))) { + throw new Error('allowFloat is false, but non-integer values given in type spec'); + } + }, + + getSpec: function() { + var spec = { + name: 'number' + }; + if (this.step !== 1) { + spec.step = this.step; + } + if (this.max != null) { + spec.max = this.max; + } + if (this.min != null) { + spec.min = this.min; + } + if (this.allowFloat) { + spec.allowFloat = true; + } + return (Object.keys(spec).length === 1) ? 'number' : spec; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return '' + value; + }, + + getMin: function(context) { + if (this.min != null) { + if (typeof this.min === 'function') { + return this.min(context); + } + if (typeof this.min === 'number') { + return this.min; + } + } + return undefined; + }, + + getMax: function(context) { + if (this.max != null) { + if (typeof this.max === 'function') { + return this.max(context); + } + if (typeof this.max === 'number') { + return this.max; + } + } + return undefined; + }, + + parse: function(arg, context) { + var msg; + if (arg.text.replace(/^\s*-?/, '').length === 0) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + if (!this.allowFloat && (arg.text.indexOf('.') !== -1)) { + msg = l10n.lookupFormat('typesNumberNotInt2', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var value; + if (this.allowFloat) { + value = parseFloat(arg.text); + } + else { + value = parseInt(arg.text, 10); + } + + if (isNaN(value)) { + msg = l10n.lookupFormat('typesNumberNan', [ arg.text ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var max = this.getMax(context); + if (max != null && value > max) { + msg = l10n.lookupFormat('typesNumberMax', [ value, max ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + var min = this.getMin(context); + if (min != null && value < min) { + msg = l10n.lookupFormat('typesNumberMin', [ value, min ]); + return Promise.resolve(new Conversion(undefined, arg, Status.ERROR, msg)); + } + + return Promise.resolve(new Conversion(value, arg)); + }, + + nudge: function(value, by, context) { + if (typeof value !== 'number' || isNaN(value)) { + if (by < 0) { + return this.getMax(context) || 1; + } + else { + var min = this.getMin(context); + return min != null ? min : 0; + } + } + + var newValue = value + (by * this.step); + + // Snap to the nearest incremental of the step + if (by < 0) { + newValue = Math.ceil(newValue / this.step) * this.step; + } + else { + newValue = Math.floor(newValue / this.step) * this.step; + if (this.getMax(context) == null) { + return newValue; + } + } + return this._boundsCheck(newValue, context); + }, + + // Return the input value so long as it is within the max/min bounds. + // If it is lower than the minimum, return the minimum. If it is bigger + // than the maximum then return the maximum. + _boundsCheck: function(value, context) { + var min = this.getMin(context); + if (min != null && value < min) { + return min; + } + var max = this.getMax(context); + if (max != null && value > max) { + return max; + } + return value; + }, + + // Return true if the given value is a finite number and not an integer, + // else return false. + _isFloat: function(value) { + return ((typeof value === 'number') && isFinite(value) && (value % 1 !== 0)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/resource.js b/devtools/shared/gcli/source/lib/gcli/types/resource.js new file mode 100644 index 000000000..cd1984824 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/resource.js @@ -0,0 +1,270 @@ +/* + * 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'; + +exports.clearResourceCache = function() { + ResourceCache.clear(); +}; + +/** + * Resources are bits of CSS and JavaScript that the page either includes + * directly or as a result of reading some remote resource. + * Resource should not be used directly, but instead through a sub-class like + * CssResource or ScriptResource. + */ +function Resource(name, type, inline, element) { + this.name = name; + this.type = type; + this.inline = inline; + this.element = element; +} + +/** + * Get the contents of the given resource as a string. + * The base Resource leaves this unimplemented. + */ +Resource.prototype.loadContents = function() { + throw new Error('not implemented'); +}; + +Resource.TYPE_SCRIPT = 'text/javascript'; +Resource.TYPE_CSS = 'text/css'; + +/** + * A CssResource provides an implementation of Resource that works for both + * [style] elements and [link type='text/css'] elements in the [head]. + */ +function CssResource(domSheet) { + this.name = domSheet.href; + if (!this.name) { + this.name = domSheet.ownerNode && domSheet.ownerNode.id ? + 'css#' + domSheet.ownerNode.id : + 'inline-css'; + } + + this.inline = (domSheet.href == null); + this.type = Resource.TYPE_CSS; + this.element = domSheet; +} + +CssResource.prototype = Object.create(Resource.prototype); + +CssResource.prototype.loadContents = function() { + return new Promise(function(resolve, reject) { + resolve(this.element.ownerNode.innerHTML); + }.bind(this)); +}; + +CssResource._getAllStyles = function(context) { + var resources = []; + if (context.environment.window == null) { + return resources; + } + + var doc = context.environment.window.document; + Array.prototype.forEach.call(doc.styleSheets, function(domSheet) { + CssResource._getStyle(domSheet, resources); + }); + + dedupe(resources, function(clones) { + for (var i = 0; i < clones.length; i++) { + clones[i].name = clones[i].name + '-' + i; + } + }); + + return resources; +}; + +CssResource._getStyle = function(domSheet, resources) { + var resource = ResourceCache.get(domSheet); + if (!resource) { + resource = new CssResource(domSheet); + ResourceCache.add(domSheet, resource); + } + resources.push(resource); + + // Look for imported stylesheets + try { + Array.prototype.forEach.call(domSheet.cssRules, function(domRule) { + if (domRule.type == CSSRule.IMPORT_RULE && domRule.styleSheet) { + CssResource._getStyle(domRule.styleSheet, resources); + } + }, this); + } + catch (ex) { + // For system stylesheets + } +}; + +/** + * A ScriptResource provides an implementation of Resource that works for + * [script] elements (both with a src attribute, and used directly). + */ +function ScriptResource(scriptNode) { + this.name = scriptNode.src; + if (!this.name) { + this.name = scriptNode.id ? + 'script#' + scriptNode.id : + 'inline-script'; + } + + this.inline = (scriptNode.src === '' || scriptNode.src == null); + this.type = Resource.TYPE_SCRIPT; + this.element = scriptNode; +} + +ScriptResource.prototype = Object.create(Resource.prototype); + +ScriptResource.prototype.loadContents = function() { + return new Promise(function(resolve, reject) { + if (this.inline) { + resolve(this.element.innerHTML); + } + else { + // It would be good if there was a better way to get the script source + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState !== xhr.DONE) { + return; + } + resolve(xhr.responseText); + }; + xhr.open('GET', this.element.src, true); + xhr.send(); + } + }.bind(this)); +}; + +ScriptResource._getAllScripts = function(context) { + if (context.environment.window == null) { + return []; + } + + var doc = context.environment.window.document; + var scriptNodes = doc.querySelectorAll('script'); + var resources = Array.prototype.map.call(scriptNodes, function(scriptNode) { + var resource = ResourceCache.get(scriptNode); + if (!resource) { + resource = new ScriptResource(scriptNode); + ResourceCache.add(scriptNode, resource); + } + return resource; + }); + + dedupe(resources, function(clones) { + for (var i = 0; i < clones.length; i++) { + clones[i].name = clones[i].name + '-' + i; + } + }); + + return resources; +}; + +/** + * Find resources with the same name, and call onDupe to change the names + */ +function dedupe(resources, onDupe) { + // first create a map of name->[array of resources with same name] + var names = {}; + resources.forEach(function(scriptResource) { + if (names[scriptResource.name] == null) { + names[scriptResource.name] = []; + } + names[scriptResource.name].push(scriptResource); + }); + + // Call the de-dupe function for each set of dupes + Object.keys(names).forEach(function(name) { + var clones = names[name]; + if (clones.length > 1) { + onDupe(clones); + } + }); +} + +/** + * A quick cache of resources against nodes + * TODO: Potential memory leak when the target document has css or script + * resources repeatedly added and removed. Solution might be to use a weak + * hash map or some such. + */ +var ResourceCache = { + _cached: [], + + /** + * Do we already have a resource that was created for the given node + */ + get: function(node) { + for (var i = 0; i < ResourceCache._cached.length; i++) { + if (ResourceCache._cached[i].node === node) { + return ResourceCache._cached[i].resource; + } + } + return null; + }, + + /** + * Add a resource for a given node + */ + add: function(node, resource) { + ResourceCache._cached.push({ node: node, resource: resource }); + }, + + /** + * Drop all cache entries. Helpful to prevent memory leaks + */ + clear: function() { + ResourceCache._cached = []; + } +}; + +/** + * The resource type itself + */ +exports.items = [ + { + item: 'type', + name: 'resource', + parent: 'selection', + cacheable: false, + include: null, + + constructor: function() { + if (this.include !== Resource.TYPE_SCRIPT && + this.include !== Resource.TYPE_CSS && + this.include != null) { + throw new Error('invalid include property: ' + this.include); + } + }, + + lookup: function(context) { + var resources = []; + if (this.include !== Resource.TYPE_SCRIPT) { + Array.prototype.push.apply(resources, + CssResource._getAllStyles(context)); + } + if (this.include !== Resource.TYPE_CSS) { + Array.prototype.push.apply(resources, + ScriptResource._getAllScripts(context)); + } + + return Promise.resolve(resources.map(function(resource) { + return { name: resource.name, value: resource }; + })); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/selection.js b/devtools/shared/gcli/source/lib/gcli/types/selection.js new file mode 100644 index 000000000..0e64c8fa2 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/selection.js @@ -0,0 +1,389 @@ +/* + * 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 l10n = require('../util/l10n'); +var spell = require('../util/spell'); +var Type = require('./types').Type; +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; +var BlankArgument = require('./types').BlankArgument; + +/** + * A selection allows the user to pick a value from known set of options. + * An option is made up of a name (which is what the user types) and a value + * (which is passed to exec) + * @param typeSpec Object containing properties that describe how this + * selection functions. Properties include: + * - lookup: An array of objects, one for each option, which contain name and + * value properties. lookup can be a function which returns this array + * - data: An array of strings - alternative to 'lookup' where the valid values + * are strings. i.e. there is no mapping between what is typed and the value + * that is used by the program + * - stringifyProperty: Conversion from value to string is generally a process + * of looking through all the valid options for a matching value, and using + * the associated name. However the name maybe available directly from the + * value using a property lookup. Setting 'stringifyProperty' allows + * SelectionType to take this shortcut. + * - cacheable: If lookup is a function, then we normally assume that + * the values fetched can change. Setting 'cacheable:true' enables internal + * caching. + */ +function SelectionType(typeSpec) { + if (typeSpec) { + Object.keys(typeSpec).forEach(function(key) { + this[key] = typeSpec[key]; + }, this); + } + + if (this.name !== 'selection' && + this.lookup == null && this.data == null) { + throw new Error(this.name + ' has no lookup or data'); + } + + this._dataToLookup = this._dataToLookup.bind(this); +} + +SelectionType.prototype = Object.create(Type.prototype); + +SelectionType.prototype.getSpec = function(commandName, paramName) { + var spec = { name: 'selection' }; + if (this.lookup != null && typeof this.lookup !== 'function') { + spec.lookup = this.lookup; + } + if (this.data != null && typeof this.data !== 'function') { + spec.data = this.data; + } + if (this.stringifyProperty != null) { + spec.stringifyProperty = this.stringifyProperty; + } + if (this.cacheable) { + spec.cacheable = true; + } + if (typeof this.lookup === 'function' || typeof this.data === 'function') { + spec.commandName = commandName; + spec.paramName = paramName; + spec.remoteLookup = true; + } + return spec; +}; + +SelectionType.prototype.stringify = function(value, context) { + if (value == null) { + return ''; + } + if (this.stringifyProperty != null) { + return value[this.stringifyProperty]; + } + + return this.getLookup(context).then(function(lookup) { + var name = null; + lookup.some(function(item) { + if (item.value === value) { + name = item.name; + return true; + } + return false; + }, this); + return name; + }.bind(this)); +}; + +/** + * If typeSpec contained cacheable:true then calls to parse() work on cached + * data. clearCache() enables the cache to be cleared. + */ +SelectionType.prototype.clearCache = function() { + this._cachedLookup = undefined; +}; + +/** + * There are several ways to get selection data. This unifies them into one + * single function. + * @return An array of objects with name and value properties. + */ +SelectionType.prototype.getLookup = function(context) { + if (this._cachedLookup != null) { + return this._cachedLookup; + } + + var reply; + + if (this.remoteLookup) { + reply = this.front.getSelectionLookup(this.commandName, this.paramName); + reply = resolve(reply, context); + } + else if (typeof this.lookup === 'function') { + reply = resolve(this.lookup.bind(this), context); + } + else if (this.lookup != null) { + reply = resolve(this.lookup, context); + } + else if (this.data != null) { + reply = resolve(this.data, context).then(this._dataToLookup); + } + else { + throw new Error(this.name + ' has no lookup or data'); + } + + if (this.cacheable) { + this._cachedLookup = reply; + } + + if (reply == null) { + console.error(arguments); + } + return reply; +}; + +/** + * Both 'lookup' and 'data' properties (see docs on SelectionType constructor) + * in addition to being real data can be a function or a promise, or even a + * function which returns a promise of real data, etc. This takes a thing and + * returns a promise of actual values. + */ +function resolve(thing, context) { + return Promise.resolve(thing).then(function(resolved) { + if (typeof resolved === 'function') { + return resolve(resolved(context), context); + } + return resolved; + }); +} + +/** + * Selection can be provided with either a lookup object (in the 'lookup' + * property) or an array of strings (in the 'data' property). Internally we + * always use lookup, so we need a way to convert a 'data' array to a lookup. + */ +SelectionType.prototype._dataToLookup = function(data) { + if (!Array.isArray(data)) { + throw new Error('data for ' + this.name + ' resolved to non-array'); + } + + return data.map(function(option) { + return { name: option, value: option }; + }); +}; + +/** + * Return a list of possible completions for the given arg. + * @param arg The initial input to match + * @return A trimmed array of string:value pairs + */ +exports.findPredictions = function(arg, lookup) { + var predictions = []; + var i, option; + var maxPredictions = Conversion.maxPredictions; + var match = arg.text.toLowerCase(); + + // If the arg has a suffix then we're kind of 'done'. Only an exact match + // will do. + if (arg.suffix.length > 0) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + predictions.push(option); + } + } + + return predictions; + } + + // Cache lower case versions of all the option names + for (i = 0; i < lookup.length; i++) { + option = lookup[i]; + if (option._gcliLowerName == null) { + option._gcliLowerName = option.name.toLowerCase(); + } + } + + // Exact hidden matches. If 'hidden: true' then we only allow exact matches + // All the tests after here check that !isHidden(option) + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option.name === arg.text) { + predictions.push(option); + } + } + + // Start with prefix matching + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) === 0 && !isHidden(option)) { + if (predictions.indexOf(option) === -1) { + predictions.push(option); + } + } + } + + // Try infix matching if we get less half max matched + if (predictions.length < (maxPredictions / 2)) { + for (i = 0; i < lookup.length && predictions.length < maxPredictions; i++) { + option = lookup[i]; + if (option._gcliLowerName.indexOf(match) !== -1 && !isHidden(option)) { + if (predictions.indexOf(option) === -1) { + predictions.push(option); + } + } + } + } + + // Try fuzzy matching if we don't get a prefix match + if (predictions.length === 0) { + var names = []; + lookup.forEach(function(opt) { + if (!isHidden(opt)) { + names.push(opt.name); + } + }); + var corrected = spell.correct(match, names); + if (corrected) { + lookup.forEach(function(opt) { + if (opt.name === corrected) { + predictions.push(opt); + } + }, this); + } + } + + return predictions; +}; + +SelectionType.prototype.parse = function(arg, context) { + return Promise.resolve(this.getLookup(context)).then(function(lookup) { + var predictions = exports.findPredictions(arg, lookup); + return exports.convertPredictions(arg, predictions); + }.bind(this)); +}; + +/** + * Decide what sort of conversion to return based on the available predictions + * and how they match the passed arg + */ +exports.convertPredictions = function(arg, predictions) { + if (predictions.length === 0) { + var msg = l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(undefined, arg, Status.ERROR, msg, + Promise.resolve(predictions)); + } + + if (predictions[0].name === arg.text) { + var value = predictions[0].value; + return new Conversion(value, arg, Status.VALID, '', + Promise.resolve(predictions)); + } + + return new Conversion(undefined, arg, Status.INCOMPLETE, '', + Promise.resolve(predictions)); +}; + +/** + * Checking that an option is hidden involves messing in properties on the + * value right now (which isn't a good idea really) we really should be marking + * that on the option, so this encapsulates the problem + */ +function isHidden(option) { + return option.hidden === true || + (option.value != null && option.value.hidden); +} + +SelectionType.prototype.getBlank = function(context) { + var predictFunc = function(context2) { + return Promise.resolve(this.getLookup(context2)).then(function(lookup) { + return lookup.filter(function(option) { + return !isHidden(option); + }).slice(0, Conversion.maxPredictions - 1); + }); + }.bind(this); + + return new Conversion(undefined, new BlankArgument(), Status.INCOMPLETE, '', + predictFunc); +}; + +/** + * Increment and decrement are confusing for selections. +1 is -1 and -1 is +1. + * Given an array e.g. [ 'a', 'b', 'c' ] with the current selection on 'b', + * displayed to the user in the natural way, i.e.: + * + * 'a' + * 'b' <- highlighted as current value + * 'c' + * + * Pressing the UP arrow should take us to 'a', which decrements this index + * (compare pressing UP on a number which would increment the number) + * + * So for selections, we treat +1 as -1 and -1 as +1. + */ +SelectionType.prototype.nudge = function(value, by, context) { + return this.getLookup(context).then(function(lookup) { + var index = this._findValue(lookup, value); + if (index === -1) { + if (by < 0) { + // We're supposed to be doing a decrement (which means +1), but the + // value isn't found, so we reset the index to the top of the list + // which is index 0 + index = 0; + } + else { + // For an increment operation when there is nothing to start from, we + // want to start from the top, i.e. index 0, so the value before we + // 'increment' (see note above) must be 1. + index = 1; + } + } + + // This is where we invert the sense of up/down (see doc comment) + index -= by; + + if (index >= lookup.length) { + index = 0; + } + return lookup[index].value; + }.bind(this)); +}; + +/** + * Walk through an array of { name:.., value:... } objects looking for a + * matching value (using strict equality), returning the matched index (or -1 + * if not found). + * @param lookup Array of objects with name/value properties to search through + * @param value The value to search for + * @return The index at which the match was found, or -1 if no match was found + */ +SelectionType.prototype._findValue = function(lookup, value) { + var index = -1; + for (var i = 0; i < lookup.length; i++) { + var pair = lookup[i]; + if (pair.value === value) { + index = i; + break; + } + } + return index; +}; + +/** + * This is how we indicate to SelectionField that we have predictions that + * might work in a menu. + */ +SelectionType.prototype.hasPredictions = true; + +SelectionType.prototype.name = 'selection'; + +exports.SelectionType = SelectionType; +exports.items = [ SelectionType ]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/setting.js b/devtools/shared/gcli/source/lib/gcli/types/setting.js new file mode 100644 index 000000000..26c6f4063 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/setting.js @@ -0,0 +1,62 @@ +/* + * 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'; + +exports.items = [ + { + // A type for selecting a known setting + item: 'type', + name: 'setting', + parent: 'selection', + cacheable: true, + lookup: function(context) { + var settings = context.system.settings; + + // Lazily add a settings.onChange listener to clear the cache + if (!this._registeredListener) { + settings.onChange.add(function(ev) { + this.clearCache(); + }, this); + this._registeredListener = true; + } + + return settings.getAll().map(function(setting) { + return { name: setting.name, value: setting }; + }); + } + }, + { + // A type for entering the value of a known setting + // Customizations: + // - settingParamName The name of the setting parameter so we can customize + // the type that we are expecting to read + item: 'type', + name: 'settingValue', + parent: 'delegate', + settingParamName: 'setting', + delegateType: function(context) { + if (context != null) { + var setting = context.getArgsObject()[this.settingParamName]; + if (setting != null) { + return setting.type; + } + } + + return 'blank'; + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/string.js b/devtools/shared/gcli/source/lib/gcli/types/string.js new file mode 100644 index 000000000..a3aebacad --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/string.js @@ -0,0 +1,92 @@ +/* + * 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 Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + // 'string' the most basic string type where all we need to do is to take + // care of converting escaped characters like \t, \n, etc. + // For the full list see + // https://developer.mozilla.org/en-US/docs/JavaScript/Guide/Values,_variables,_and_literals + // The exception is that we ignore \b because replacing '\b' characters in + // stringify() with their escaped version injects '\\b' all over the place + // and the need to support \b seems low) + // Customizations: + // allowBlank: Allow a blank string to be counted as valid + item: 'type', + name: 'string', + allowBlank: false, + + getSpec: function() { + return this.allowBlank ? + { name: 'string', allowBlank: true } : + 'string'; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + + return value + .replace(/\\/g, '\\\\') + .replace(/\f/g, '\\f') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/\v/g, '\\v') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/ /g, '\\ ') + .replace(/'/g, '\\\'') + .replace(/"/g, '\\"') + .replace(/{/g, '\\{') + .replace(/}/g, '\\}'); + }, + + parse: function(arg, context) { + if (!this.allowBlank && (arg.text == null || arg.text === '')) { + return Promise.resolve(new Conversion(undefined, arg, Status.INCOMPLETE, '')); + } + + // The string '\\' (i.e. an escaped \ (represented here as '\\\\' because it + // is double escaped)) is first converted to a private unicode character and + // then at the end from \uF000 to a single '\' to avoid the string \\n being + // converted first to \n and then to a <LF> + var value = arg.text + .replace(/\\\\/g, '\uF000') + .replace(/\\f/g, '\f') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\v/g, '\v') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\ /g, ' ') + .replace(/\\'/g, '\'') + .replace(/\\"/g, '"') + .replace(/\\{/g, '{') + .replace(/\\}/g, '}') + .replace(/\uF000/g, '\\'); + + return Promise.resolve(new Conversion(value, arg)); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/types.js b/devtools/shared/gcli/source/lib/gcli/types/types.js new file mode 100644 index 000000000..ed5a93d54 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/types.js @@ -0,0 +1,1146 @@ +/* + * 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'); + +/** + * We record where in the input string an argument comes so we can report + * errors against those string positions. + * @param text The string (trimmed) that contains the argument + * @param prefix Knowledge of quotation marks and whitespace used prior to the + * text in the input string allows us to re-generate the original input from + * the arguments. + * @param suffix Any quotation marks and whitespace used after the text. + * Whitespace is normally placed in the prefix to the succeeding argument, but + * can be used here when this is the last argument. + * @constructor + */ +function Argument(text, prefix, suffix) { + if (text === undefined) { + this.text = ''; + this.prefix = ''; + this.suffix = ''; + } + else { + this.text = text; + this.prefix = prefix !== undefined ? prefix : ''; + this.suffix = suffix !== undefined ? suffix : ''; + } +} + +Argument.prototype.type = 'Argument'; + +/** + * Return the result of merging these arguments. + * case and some of the arguments are in quotation marks? + */ +Argument.prototype.merge = function(following) { + // Is it possible that this gets called when we're merging arguments + // for the single string? + return new Argument( + this.text + this.suffix + following.prefix + following.text, + this.prefix, following.suffix); +}; + +/** + * Returns a new Argument like this one but with various items changed. + * @param options Values to use in creating a new Argument. + * Warning: some implementations of beget make additions to the options + * argument. You should be aware of this in the unlikely event that you want to + * reuse 'options' arguments. + * Properties: + * - text: The new text value + * - prefixSpace: Should the prefix be altered to begin with a space? + * - prefixPostSpace: Should the prefix be altered to end with a space? + * - suffixSpace: Should the suffix be altered to end with a space? + * - type: Constructor to use in creating new instances. Default: Argument + * - dontQuote: Should we avoid adding prefix/suffix quotes when the text value + * has a space? Needed when we're completing a sub-command. + */ +Argument.prototype.beget = function(options) { + var text = this.text; + var prefix = this.prefix; + var suffix = this.suffix; + + if (options.text != null) { + text = options.text; + + // We need to add quotes when the replacement string has spaces or is empty + if (!options.dontQuote) { + var needsQuote = text.indexOf(' ') >= 0 || text.length === 0; + var hasQuote = /['"]$/.test(prefix); + if (needsQuote && !hasQuote) { + prefix = prefix + '\''; + suffix = '\'' + suffix; + } + } + } + + if (options.prefixSpace && prefix.charAt(0) !== ' ') { + prefix = ' ' + prefix; + } + + if (options.prefixPostSpace && prefix.charAt(prefix.length - 1) !== ' ') { + prefix = prefix + ' '; + } + + if (options.suffixSpace && suffix.charAt(suffix.length - 1) !== ' ') { + suffix = suffix + ' '; + } + + if (text === this.text && suffix === this.suffix && prefix === this.prefix) { + return this; + } + + var ArgumentType = options.type || Argument; + return new ArgumentType(text, prefix, suffix); +}; + +/** + * We need to keep track of which assignment we've been assigned to + */ +Object.defineProperty(Argument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { this._assignment = assignment; }, + enumerable: true +}); + +/** + * Sub-classes of Argument are collections of arguments, getArgs() gets access + * to the members of the collection in order to do things like re-create input + * command lines. For the simple Argument case it's just an array containing + * only this. + */ +Argument.prototype.getArgs = function() { + return [ this ]; +}; + +/** + * We define equals to mean all arg properties are strict equals. + * Used by Conversion.argEquals and Conversion.equals and ultimately + * Assignment.equals to avoid reporting a change event when a new conversion + * is assigned. + */ +Argument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof Argument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * Helper when we're putting arguments back together + */ +Argument.prototype.toString = function() { + // BUG 664207: We should re-escape escaped characters + // But can we do that reliably? + return this.prefix + this.text + this.suffix; +}; + +/** + * Merge an array of arguments into a single argument. + * All Arguments in the array are expected to have the same emitter + */ +Argument.merge = function(argArray, start, end) { + start = (start === undefined) ? 0 : start; + end = (end === undefined) ? argArray.length : end; + + var joined; + for (var i = start; i < end; i++) { + var arg = argArray[i]; + if (!joined) { + joined = arg; + } + else { + joined = joined.merge(arg); + } + } + return joined; +}; + +/** + * For test/debug use only. The output from this function is subject to wanton + * random change without notice, and should not be relied upon to even exist + * at some later date. + */ +Object.defineProperty(Argument.prototype, '_summaryJson', { + get: function() { + var assignStatus = this.assignment == null ? + 'null' : + this.assignment.param.name; + return '<' + this.prefix + ':' + this.text + ':' + this.suffix + '>' + + ' (a=' + assignStatus + ',' + ' t=' + this.type + ')'; + }, + enumerable: true +}); + +exports.Argument = Argument; + + +/** + * BlankArgument is a marker that the argument wasn't typed but is there to + * fill a slot. Assignments begin with their arg set to a BlankArgument. + */ +function BlankArgument() { + this.text = ''; + this.prefix = ''; + this.suffix = ''; +} + +BlankArgument.prototype = Object.create(Argument.prototype); + +BlankArgument.prototype.type = 'BlankArgument'; + +exports.BlankArgument = BlankArgument; + + +/** + * ScriptArgument is a marker that the argument is designed to be JavaScript. + * It also implements the special rules that spaces after the { or before the + * } are part of the pre/suffix rather than the content, and that they are + * never 'blank' so they can be used by Requisition._split() and not raise an + * ERROR status due to being blank. + */ +function ScriptArgument(text, prefix, suffix) { + this.text = text !== undefined ? text : ''; + this.prefix = prefix !== undefined ? prefix : ''; + this.suffix = suffix !== undefined ? suffix : ''; + + ScriptArgument._moveSpaces(this); +} + +ScriptArgument.prototype = Object.create(Argument.prototype); + +ScriptArgument.prototype.type = 'ScriptArgument'; + +/** + * Private/Dangerous: Alters a ScriptArgument to move the spaces at the start + * or end of the 'text' into the prefix/suffix. With a string, " a " is 3 chars + * long, but with a ScriptArgument, { a } is only one char long. + * Arguments are generally supposed to be immutable, so this method should only + * be called on a ScriptArgument that isn't exposed to the outside world yet. + */ +ScriptArgument._moveSpaces = function(arg) { + while (arg.text.charAt(0) === ' ') { + arg.prefix = arg.prefix + ' '; + arg.text = arg.text.substring(1); + } + + while (arg.text.charAt(arg.text.length - 1) === ' ') { + arg.suffix = ' ' + arg.suffix; + arg.text = arg.text.slice(0, -1); + } +}; + +/** + * As Argument.beget that implements the space rule documented in the ctor. + */ +ScriptArgument.prototype.beget = function(options) { + options.type = ScriptArgument; + var begotten = Argument.prototype.beget.call(this, options); + ScriptArgument._moveSpaces(begotten); + return begotten; +}; + +exports.ScriptArgument = ScriptArgument; + + +/** + * Commands like 'echo' with a single string argument, and used with the + * special format like: 'echo a b c' effectively have a number of arguments + * merged together. + */ +function MergedArgument(args, start, end) { + if (!Array.isArray(args)) { + throw new Error('args is not an array of Arguments'); + } + + if (start === undefined) { + this.args = args; + } + else { + this.args = args.slice(start, end); + } + + var arg = Argument.merge(this.args); + this.text = arg.text; + this.prefix = arg.prefix; + this.suffix = arg.suffix; +} + +MergedArgument.prototype = Object.create(Argument.prototype); + +MergedArgument.prototype.type = 'MergedArgument'; + +/** + * Keep track of which assignment we've been assigned to, and allow the + * original args to do the same. + */ +Object.defineProperty(MergedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.args.forEach(function(arg) { + arg.assignment = assignment; + }, this); + }, + enumerable: true +}); + +MergedArgument.prototype.getArgs = function() { + return this.args; +}; + +MergedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof MergedArgument)) { + return false; + } + + // We might need to add a check that args is the same here + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +exports.MergedArgument = MergedArgument; + + +/** + * TrueNamedArguments are for when we have an argument like --verbose which + * has a boolean value, and thus the opposite of '--verbose' is ''. + */ +function TrueNamedArgument(arg) { + this.arg = arg; + this.text = arg.text; + this.prefix = arg.prefix; + this.suffix = arg.suffix; +} + +TrueNamedArgument.prototype = Object.create(Argument.prototype); + +TrueNamedArgument.prototype.type = 'TrueNamedArgument'; + +Object.defineProperty(TrueNamedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + if (this.arg) { + this.arg.assignment = assignment; + } + }, + enumerable: true +}); + +TrueNamedArgument.prototype.getArgs = function() { + return [ this.arg ]; +}; + +TrueNamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof TrueNamedArgument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * As Argument.beget that rebuilds nameArg and valueArg + */ +TrueNamedArgument.prototype.beget = function(options) { + if (options.text) { + console.error('Can\'t change text of a TrueNamedArgument', this, options); + } + + options.type = TrueNamedArgument; + var begotten = Argument.prototype.beget.call(this, options); + begotten.arg = new Argument(begotten.text, begotten.prefix, begotten.suffix); + return begotten; +}; + +exports.TrueNamedArgument = TrueNamedArgument; + + +/** + * FalseNamedArguments are for when we don't have an argument like --verbose + * which has a boolean value, and thus the opposite of '' is '--verbose'. + */ +function FalseNamedArgument() { + this.text = ''; + this.prefix = ''; + this.suffix = ''; +} + +FalseNamedArgument.prototype = Object.create(Argument.prototype); + +FalseNamedArgument.prototype.type = 'FalseNamedArgument'; + +FalseNamedArgument.prototype.getArgs = function() { + return [ ]; +}; + +FalseNamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null || !(that instanceof FalseNamedArgument)) { + return false; + } + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +exports.FalseNamedArgument = FalseNamedArgument; + + +/** + * A named argument is for cases where we have input in one of the following + * formats: + * <ul> + * <li>--param value + * <li>-p value + * </ul> + * We model this as a normal argument but with a long prefix. + * + * There are 2 ways to construct a NamedArgument. One using 2 Arguments which + * are taken to be the argument for the name (e.g. '--param') and one for the + * value to assign to that parameter. + * Alternatively, you can pass in the text/prefix/suffix values in the same + * way as an Argument is constructed. If you do this then you are expected to + * assign to nameArg and valueArg before exposing the new NamedArgument. + */ +function NamedArgument() { + if (typeof arguments[0] === 'string') { + this.nameArg = null; + this.valueArg = null; + this.text = arguments[0]; + this.prefix = arguments[1]; + this.suffix = arguments[2]; + } + else if (arguments[1] == null) { + this.nameArg = arguments[0]; + this.valueArg = null; + this.text = ''; + this.prefix = this.nameArg.toString(); + this.suffix = ''; + } + else { + this.nameArg = arguments[0]; + this.valueArg = arguments[1]; + this.text = this.valueArg.text; + this.prefix = this.nameArg.toString() + this.valueArg.prefix; + this.suffix = this.valueArg.suffix; + } +} + +NamedArgument.prototype = Object.create(Argument.prototype); + +NamedArgument.prototype.type = 'NamedArgument'; + +Object.defineProperty(NamedArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.nameArg.assignment = assignment; + if (this.valueArg != null) { + this.valueArg.assignment = assignment; + } + }, + enumerable: true +}); + +NamedArgument.prototype.getArgs = function() { + return this.valueArg ? [ this.nameArg, this.valueArg ] : [ this.nameArg ]; +}; + +NamedArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + + if (!(that instanceof NamedArgument)) { + return false; + } + + // We might need to add a check that nameArg and valueArg are the same + + return this.text === that.text && + this.prefix === that.prefix && this.suffix === that.suffix; +}; + +/** + * As Argument.beget that rebuilds nameArg and valueArg + */ +NamedArgument.prototype.beget = function(options) { + options.type = NamedArgument; + var begotten = Argument.prototype.beget.call(this, options); + + // Cut the prefix into |whitespace|non-whitespace|whitespace+quote so we can + // rebuild nameArg and valueArg from the parts + var matches = /^([\s]*)([^\s]*)([\s]*['"]?)$/.exec(begotten.prefix); + + if (this.valueArg == null && begotten.text === '') { + begotten.nameArg = new Argument(matches[2], matches[1], matches[3]); + begotten.valueArg = null; + } + else { + begotten.nameArg = new Argument(matches[2], matches[1], ''); + begotten.valueArg = new Argument(begotten.text, matches[3], begotten.suffix); + } + + return begotten; +}; + +exports.NamedArgument = NamedArgument; + + +/** + * An argument the groups together a number of plain arguments together so they + * can be jointly assigned to a single array parameter + */ +function ArrayArgument() { + this.args = []; +} + +ArrayArgument.prototype = Object.create(Argument.prototype); + +ArrayArgument.prototype.type = 'ArrayArgument'; + +ArrayArgument.prototype.addArgument = function(arg) { + this.args.push(arg); +}; + +ArrayArgument.prototype.addArguments = function(args) { + Array.prototype.push.apply(this.args, args); +}; + +ArrayArgument.prototype.getArguments = function() { + return this.args; +}; + +Object.defineProperty(ArrayArgument.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.args.forEach(function(arg) { + arg.assignment = assignment; + }, this); + }, + enumerable: true +}); + +ArrayArgument.prototype.getArgs = function() { + return this.args; +}; + +ArrayArgument.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + + if (that.type !== 'ArrayArgument') { + return false; + } + + if (this.args.length !== that.args.length) { + return false; + } + + for (var i = 0; i < this.args.length; i++) { + if (!this.args[i].equals(that.args[i])) { + return false; + } + } + + return true; +}; + +/** + * Helper when we're putting arguments back together + */ +ArrayArgument.prototype.toString = function() { + return '{' + this.args.map(function(arg) { + return arg.toString(); + }, this).join(',') + '}'; +}; + +exports.ArrayArgument = ArrayArgument; + +/** + * Some types can detect validity, that is to say they can distinguish between + * valid and invalid values. + * We might want to change these constants to be numbers for better performance + */ +var Status = { + /** + * The conversion process worked without any problem, and the value is + * valid. There are a number of failure states, so the best way to check + * for failure is (x !== Status.VALID) + */ + VALID: { + toString: function() { return 'VALID'; }, + valueOf: function() { return 0; } + }, + + /** + * A conversion process failed, however it was noted that the string + * provided to 'parse()' could be VALID by the addition of more characters, + * so the typing may not be actually incorrect yet, just unfinished. + * @see Status.ERROR + */ + INCOMPLETE: { + toString: function() { return 'INCOMPLETE'; }, + valueOf: function() { return 1; } + }, + + /** + * The conversion process did not work, the value should be null and a + * reason for failure should have been provided. In addition some + * completion values may be available. + * @see Status.INCOMPLETE + */ + ERROR: { + toString: function() { return 'ERROR'; }, + valueOf: function() { return 2; } + }, + + /** + * A combined status is the worser of the provided statuses. The statuses + * can be provided either as a set of arguments or a single array + */ + combine: function() { + var combined = Status.VALID; + for (var i = 0; i < arguments.length; i++) { + var status = arguments[i]; + if (Array.isArray(status)) { + status = Status.combine.apply(null, status); + } + if (status > combined) { + combined = status; + } + } + return combined; + }, + + fromString: function(str) { + switch (str) { + case Status.VALID.toString(): + return Status.VALID; + case Status.INCOMPLETE.toString(): + return Status.INCOMPLETE; + case Status.ERROR.toString(): + return Status.ERROR; + default: + throw new Error('\'' + str + '\' is not a status'); + } + } +}; + +exports.Status = Status; + + +/** + * The type.parse() method converts an Argument into a value, Conversion is + * a wrapper to that value. + * Conversion is needed to collect a number of properties related to that + * conversion in one place, i.e. to handle errors and provide traceability. + * @param value The result of the conversion. null if status == VALID + * @param arg The data from which the conversion was made + * @param status See the Status values [VALID|INCOMPLETE|ERROR] defined above. + * The default status is Status.VALID. + * @param message If status=ERROR, there should be a message to describe the + * error. A message is not needed unless for other statuses, but could be + * present for any status including VALID (in the case where we want to note a + * warning, for example). + * See BUG 664676: GCLI conversion error messages should be localized + * @param predictions If status=INCOMPLETE, there could be predictions as to + * the options available to complete the input. + * We generally expect there to be about 7 predictions (to match human list + * comprehension ability) however it is valid to provide up to about 20, + * or less. It is the job of the predictor to decide a smart cut-off. + * For example if there are 4 very good matches and 4 very poor ones, + * probably only the 4 very good matches should be presented. + * The predictions are presented either as an array of prediction objects or as + * a function which returns this array when called with no parameters. + * Each prediction object has the following shape: + * { + * name: '...', // textual completion. i.e. what the cli uses + * value: { ... }, // value behind the textual completion + * incomplete: true // this completion is only partial (optional) + * } + * The 'incomplete' property could be used to denote a valid completion which + * could have sub-values (e.g. for tree navigation). + */ +function Conversion(value, arg, status, message, predictions) { + if (arg == null) { + throw new Error('Missing arg'); + } + + if (predictions != null && typeof predictions !== 'function' && + !Array.isArray(predictions) && typeof predictions.then !== 'function') { + throw new Error('predictions exists but is not a promise, function or array'); + } + + if (status === Status.ERROR && !message) { + throw new Error('Conversion has status=ERROR but no message'); + } + + this.value = value; + this.arg = arg; + this._status = status || Status.VALID; + this.message = message; + this.predictions = predictions; +} + +/** + * Ensure that all arguments that are part of this conversion know what they + * are assigned to. + * @param assignment The Assignment (param/conversion link) to inform the + * argument about. + */ +Object.defineProperty(Conversion.prototype, 'assignment', { + get: function() { return this.arg.assignment; }, + set: function(assignment) { this.arg.assignment = assignment; }, + enumerable: true +}); + +/** + * Work out if there is information provided in the contained argument. + */ +Conversion.prototype.isDataProvided = function() { + return this.arg.type !== 'BlankArgument'; +}; + +/** + * 2 conversions are equal if and only if their args are equal (argEquals) and + * their values are equal (valueEquals). + * @param that The conversion object to compare against. + */ +Conversion.prototype.equals = function(that) { + if (this === that) { + return true; + } + if (that == null) { + return false; + } + return this.valueEquals(that) && this.argEquals(that); +}; + +/** + * Check that the value in this conversion is strict equal to the value in the + * provided conversion. + * @param that The conversion to compare values with + */ +Conversion.prototype.valueEquals = function(that) { + return that != null && this.value === that.value; +}; + +/** + * Check that the argument in this conversion is equal to the value in the + * provided conversion as defined by the argument (i.e. arg.equals). + * @param that The conversion to compare arguments with + */ +Conversion.prototype.argEquals = function(that) { + return that == null ? false : this.arg.equals(that.arg); +}; + +/** + * Accessor for the status of this conversion + */ +Conversion.prototype.getStatus = function(arg) { + return this._status; +}; + +/** + * Defined by the toString() value provided by the argument + */ +Conversion.prototype.toString = function() { + return this.arg.toString(); +}; + +/** + * If status === INCOMPLETE, then we may be able to provide predictions as to + * how the argument can be completed. + * @return An array of items, or a promise of an array of items, where each + * item is an object with the following properties: + * - name (mandatory): Displayed to the user, and typed in. No whitespace + * - description (optional): Short string for display in a tool-tip + * - manual (optional): Longer description which details usage + * - incomplete (optional): Indicates that the prediction if used should not + * be considered necessarily sufficient, which typically will mean that the + * UI should not append a space to the completion + * - value (optional): If a value property is present, this will be used as the + * value of the conversion, otherwise the item itself will be used. + */ +Conversion.prototype.getPredictions = function(context) { + if (typeof this.predictions === 'function') { + return this.predictions(context); + } + return Promise.resolve(this.predictions || []); +}; + +/** + * Return a promise of an index constrained by the available predictions. + * i.e. (index % predicitons.length) + * This code can probably be removed when the Firefox developer toolbar isn't + * needed any more. + */ +Conversion.prototype.constrainPredictionIndex = function(context, index) { + if (index == null) { + return Promise.resolve(); + } + + return this.getPredictions(context).then(function(value) { + if (value.length === 0) { + return undefined; + } + + index = index % value.length; + if (index < 0) { + index = value.length + index; + } + return index; + }.bind(this)); +}; + +/** + * Constant to allow everyone to agree on the maximum number of predictions + * that should be provided. We actually display 1 less than this number. + */ +Conversion.maxPredictions = 9; + +exports.Conversion = Conversion; + + +/** + * ArrayConversion is a special Conversion, needed because arrays are converted + * member by member rather then as a whole, which means we can track the + * conversion if individual array elements. So an ArrayConversion acts like a + * normal Conversion (which is needed as Assignment requires a Conversion) but + * it can also be devolved into a set of Conversions for each array member. + */ +function ArrayConversion(conversions, arg) { + this.arg = arg; + this.conversions = conversions; + this.value = conversions.map(function(conversion) { + return conversion.value; + }, this); + + this._status = Status.combine(conversions.map(function(conversion) { + return conversion.getStatus(); + })); + + // This message is just for reporting errors like "not enough values" + // rather that for problems with individual values. + this.message = ''; + + // Predictions are generally provided by individual values + this.predictions = []; +} + +ArrayConversion.prototype = Object.create(Conversion.prototype); + +Object.defineProperty(ArrayConversion.prototype, 'assignment', { + get: function() { return this._assignment; }, + set: function(assignment) { + this._assignment = assignment; + + this.conversions.forEach(function(conversion) { + conversion.assignment = assignment; + }, this); + }, + enumerable: true +}); + +ArrayConversion.prototype.getStatus = function(arg) { + if (arg && arg.conversion) { + return arg.conversion.getStatus(); + } + return this._status; +}; + +ArrayConversion.prototype.isDataProvided = function() { + return this.conversions.length > 0; +}; + +ArrayConversion.prototype.valueEquals = function(that) { + if (that == null) { + return false; + } + + if (!(that instanceof ArrayConversion)) { + throw new Error('Can\'t compare values with non ArrayConversion'); + } + + if (this.value === that.value) { + return true; + } + + if (this.value.length !== that.value.length) { + return false; + } + + for (var i = 0; i < this.conversions.length; i++) { + if (!this.conversions[i].valueEquals(that.conversions[i])) { + return false; + } + } + + return true; +}; + +ArrayConversion.prototype.toString = function() { + return '[ ' + this.conversions.map(function(conversion) { + return conversion.toString(); + }, this).join(', ') + ' ]'; +}; + +exports.ArrayConversion = ArrayConversion; + + +/** + * Most of our types are 'static' e.g. there is only one type of 'string', + * however some types like 'selection' and 'delegate' are customizable. + * The basic Type type isn't useful, but does provide documentation about what + * types do. + */ +function Type() { +} + +/** + * Get a JSONable data structure that entirely describes this type. + * commandName and paramName are the names of the command and parameter which + * we are remoting to help the server get back to the remoted action. + */ +Type.prototype.getSpec = function(commandName, paramName) { + throw new Error('Not implemented'); +}; + +/** + * Convert the given <tt>value</tt> to a string representation. + * Where possible, there should be round-tripping between values and their + * string representations. + * @param value The object to convert into a string + * @param context An ExecutionContext to allow basic Requisition access + */ +Type.prototype.stringify = function(value, context) { + throw new Error('Not implemented'); +}; + +/** + * Convert the given <tt>arg</tt> to an instance of this type. + * Where possible, there should be round-tripping between values and their + * string representations. + * @param arg An instance of <tt>Argument</tt> to convert. + * @param context An ExecutionContext to allow basic Requisition access + * @return Conversion + */ +Type.prototype.parse = function(arg, context) { + throw new Error('Not implemented'); +}; + +/** + * A convenience method for times when you don't have an argument to parse + * but instead have a string. + * @see #parse(arg) + */ +Type.prototype.parseString = function(str, context) { + return this.parse(new Argument(str), context); +}; + +/** + * The plug-in system, and other things need to know what this type is + * called. The name alone is not enough to fully specify a type. Types like + * 'selection' and 'delegate' need extra data, however this function returns + * only the name, not the extra data. + */ +Type.prototype.name = undefined; + +/** + * If there is some concept of a lower or higher value, return it, + * otherwise return undefined. + * @param by number indicating how much to nudge by, usually +1 or -1 which is + * caused by the user pressing the UP/DOWN keys with the cursor in this type + */ +Type.prototype.nudge = function(value, by, context) { + return undefined; +}; + +/** + * The 'blank value' of most types is 'undefined', but there are exceptions; + * This allows types to specify a better conversion from empty string than + * 'undefined'. + * 2 known examples of this are boolean -> false and array -> [] + */ +Type.prototype.getBlank = function(context) { + return new Conversion(undefined, new BlankArgument(), Status.INCOMPLETE, ''); +}; + +/** + * This is something of a hack for the benefit of DelegateType which needs to + * be able to lie about it's type for fields to accept it as one of their own. + * Sub-types can ignore this unless they're DelegateType. + * @param context An ExecutionContext to allow basic Requisition access + */ +Type.prototype.getType = function(context) { + return this; +}; + +/** + * addItems allows registrations of a number of things. This allows it to know + * what type of item, and how it should be registered. + */ +Type.prototype.item = 'type'; + +exports.Type = Type; + +/** + * 'Types' represents a registry of types + */ +function Types() { + // Invariant: types[name] = type.name + this._registered = {}; +} + +exports.Types = Types; + +/** + * Get an array of the names of registered types + */ +Types.prototype.getTypeNames = function() { + return Object.keys(this._registered); +}; + +/** + * Add a new type to the list available to the system. + * You can pass 2 things to this function - either an instance of Type, in + * which case we return this instance when #getType() is called with a 'name' + * that matches type.name. + * Also you can pass in a constructor (i.e. function) in which case when + * #getType() is called with a 'name' that matches Type.prototype.name we will + * pass the typeSpec into this constructor. + */ +Types.prototype.add = function(type) { + if (typeof type === 'object') { + if (!type.name) { + throw new Error('All registered types must have a name'); + } + + if (type instanceof Type) { + this._registered[type.name] = type; + } + else { + var name = type.name; + var parent = type.parent; + type.name = parent; + delete type.parent; + + this._registered[name] = this.createType(type); + + type.name = name; + type.parent = parent; + } + } + else if (typeof type === 'function') { + if (!type.prototype.name) { + throw new Error('All registered types must have a name'); + } + this._registered[type.prototype.name] = type; + } + else { + throw new Error('Unknown type: ' + type); + } +}; + +/** + * Remove a type from the list available to the system + */ +Types.prototype.remove = function(type) { + delete this._registered[type.name]; +}; + +/** + * Find a previously registered type + */ +Types.prototype.createType = function(typeSpec) { + if (typeof typeSpec === 'string') { + typeSpec = { name: typeSpec }; + } + + if (typeof typeSpec !== 'object') { + throw new Error('Can\'t extract type from ' + typeSpec); + } + + var NewTypeCtor, newType; + if (typeSpec.name == null || typeSpec.name == 'type') { + NewTypeCtor = Type; + } + else { + NewTypeCtor = this._registered[typeSpec.name]; + } + + if (!NewTypeCtor) { + console.error('Known types: ' + Object.keys(this._registered).join(', ')); + throw new Error('Unknown type: \'' + typeSpec.name + '\''); + } + + if (typeof NewTypeCtor === 'function') { + newType = new NewTypeCtor(typeSpec); + } + else { + // clone 'type' + newType = {}; + util.copyProperties(NewTypeCtor, newType); + } + + // Copy the properties of typeSpec onto the new type + util.copyProperties(typeSpec, newType); + + // Several types need special powers to create child types + newType.types = this; + + if (typeof NewTypeCtor !== 'function') { + if (typeof newType.constructor === 'function') { + newType.constructor(); + } + } + + return newType; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/types/union.js b/devtools/shared/gcli/source/lib/gcli/types/union.js new file mode 100644 index 000000000..c98d3411b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/union.js @@ -0,0 +1,117 @@ +/* + * Copyright 2014, 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 l10n = require('../util/l10n'); +var Conversion = require('./types').Conversion; +var Status = require('./types').Status; + +exports.items = [ + { + // The union type allows for a combination of different parameter types. + item: 'type', + name: 'union', + hasPredictions: true, + + constructor: function() { + // Get the properties of the type. Later types in the list should always + // be more general, so 'catch all' types like string must be last + this.alternatives = this.alternatives.map(function(typeData) { + return this.types.createType(typeData); + }.bind(this)); + }, + + getSpec: function(command, param) { + var spec = { name: 'union', alternatives: [] }; + this.alternatives.forEach(function(type) { + spec.alternatives.push(type.getSpec(command, param)); + }.bind(this)); + return spec; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + + var type = this.alternatives.find(function(typeData) { + return typeData.name === value.type; + }); + + return type.stringify(value[value.type], context); + }, + + parse: function(arg, context) { + var conversionPromises = this.alternatives.map(function(type) { + return type.parse(arg, context); + }.bind(this)); + + return Promise.all(conversionPromises).then(function(conversions) { + // Find a list of the predictions made by any conversion + var predictionPromises = conversions.map(function(conversion) { + return conversion.getPredictions(context); + }.bind(this)); + + return Promise.all(predictionPromises).then(function(allPredictions) { + // Take one prediction from each set of predictions, ignoring + // duplicates, until we've got up to Conversion.maxPredictions + var maxIndex = allPredictions.reduce(function(prev, prediction) { + return Math.max(prev, prediction.length); + }.bind(this), 0); + var predictions = []; + + indexLoop: + for (var index = 0; index < maxIndex; index++) { + for (var p = 0; p <= allPredictions.length; p++) { + if (predictions.length >= Conversion.maxPredictions) { + break indexLoop; + } + + if (allPredictions[p] != null) { + var prediction = allPredictions[p][index]; + if (prediction != null && predictions.indexOf(prediction) === -1) { + predictions.push(prediction); + } + } + } + } + + var bestStatus = Status.ERROR; + var value; + for (var i = 0; i < conversions.length; i++) { + var conversion = conversions[i]; + var thisStatus = conversion.getStatus(arg); + if (thisStatus < bestStatus) { + bestStatus = thisStatus; + } + if (bestStatus === Status.VALID) { + var type = this.alternatives[i].name; + value = { type: type }; + value[type] = conversion.value; + break; + } + } + + var msg = (bestStatus === Status.VALID) ? + '' : + l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]); + return new Conversion(value, arg, bestStatus, msg, predictions); + }.bind(this)); + }.bind(this)); + }, + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/types/url.js b/devtools/shared/gcli/source/lib/gcli/types/url.js new file mode 100644 index 000000000..73895d66b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/types/url.js @@ -0,0 +1,86 @@ +/* + * 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 host = require('../util/host'); +var Status = require('./types').Status; +var Conversion = require('./types').Conversion; + +exports.items = [ + { + item: 'type', + name: 'url', + + getSpec: function() { + return 'url'; + }, + + stringify: function(value, context) { + if (value == null) { + return ''; + } + return value.href; + }, + + parse: function(arg, context) { + var conversion; + + try { + var url = host.createUrl(arg.text); + conversion = new Conversion(url, arg); + } + catch (ex) { + var predictions = []; + var status = Status.ERROR; + + // Maybe the URL was missing a scheme? + if (arg.text.indexOf('://') === -1) { + [ 'http', 'https' ].forEach(function(scheme) { + try { + var http = host.createUrl(scheme + '://' + arg.text); + predictions.push({ name: http.href, value: http }); + } + catch (ex) { + // Ignore + } + }.bind(this)); + + // Try to create a URL with the current page as a base ref + if ('window' in context.environment) { + try { + var base = context.environment.window.location.href; + var localized = host.createUrl(arg.text, base); + predictions.push({ name: localized.href, value: localized }); + } + catch (ex) { + // Ignore + } + } + } + + if (predictions.length > 0) { + status = Status.INCOMPLETE; + } + + conversion = new Conversion(undefined, arg, status, + ex.message, predictions); + } + + return Promise.resolve(conversion); + } + } +]; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/focus.js b/devtools/shared/gcli/source/lib/gcli/ui/focus.js new file mode 100644 index 000000000..6d3761cca --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/focus.js @@ -0,0 +1,403 @@ +/* + * 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'); + +/** + * Record how much help the user wants from the tooltip + */ +var Eagerness = { + NEVER: 1, + SOMETIMES: 2, + ALWAYS: 3 +}; + +/** + * Export the eagerHelper setting + */ +exports.items = [ + { + item: 'setting', + name: 'eagerHelper', + type: { + name: 'selection', + lookup: [ + { name: 'never', value: Eagerness.NEVER }, + { name: 'sometimes', value: Eagerness.SOMETIMES }, + { name: 'always', value: Eagerness.ALWAYS } + ] + }, + defaultValue: Eagerness.SOMETIMES, + description: l10n.lookup('eagerHelperDesc'), + ignoreTypeDifference: true + } +]; + +/** + * FocusManager solves the problem of tracking focus among a set of nodes. + * The specific problem we are solving is when the hint element must be visible + * if either the command line or any of the inputs in the hint element has the + * focus, and invisible at other times, without hiding and showing the hint + * element even briefly as the focus changes between them. + * It does this simply by postponing the hide events by 250ms to see if + * something else takes focus. + */ +function FocusManager(document, settings) { + if (document == null) { + throw new Error('document == null'); + } + + this.document = document; + this.settings = settings; + this.debug = false; + this.blurDelay = 150; + this.window = this.document.defaultView; + + this._blurDelayTimeout = null; // Result of setTimeout in delaying a blur + this._monitoredElements = []; // See addMonitoredElement() + + this._isError = false; + this._hasFocus = false; + this._helpRequested = false; + this._recentOutput = false; + + this.onVisibilityChange = util.createEvent('FocusManager.onVisibilityChange'); + + this._focused = this._focused.bind(this); + if (this.document.addEventListener) { + this.document.addEventListener('focus', this._focused, true); + } + + var eagerHelper = this.settings.get('eagerHelper'); + eagerHelper.onChange.add(this._eagerHelperChanged, this); + + this.isTooltipVisible = undefined; + this.isOutputVisible = undefined; + this._checkShow(); +} + +/** + * Avoid memory leaks + */ +FocusManager.prototype.destroy = function() { + var eagerHelper = this.settings.get('eagerHelper'); + eagerHelper.onChange.remove(this._eagerHelperChanged, this); + + this.document.removeEventListener('focus', this._focused, true); + + for (var i = 0; i < this._monitoredElements.length; i++) { + var monitor = this._monitoredElements[i]; + console.error('Hanging monitored element: ', monitor.element); + + monitor.element.removeEventListener('focus', monitor.onFocus, true); + monitor.element.removeEventListener('blur', monitor.onBlur, true); + } + + if (this._blurDelayTimeout) { + this.window.clearTimeout(this._blurDelayTimeout); + this._blurDelayTimeout = null; + } + + this._focused = undefined; + this.document = undefined; + this.settings = undefined; + this.window = undefined; +}; + +/** + * The easy way to include an element in the set of things that are part of the + * aggregate focus. Using [add|remove]MonitoredElement() is a simpler way of + * option than calling report[Focus|Blur]() + * @param element The element on which to track focus|blur events + * @param where Optional source string for debugging only + */ +FocusManager.prototype.addMonitoredElement = function(element, where) { + if (this.debug) { + console.log('FocusManager.addMonitoredElement(' + (where || 'unknown') + ')'); + } + + var monitor = { + element: element, + where: where, + onFocus: function() { this._reportFocus(where); }.bind(this), + onBlur: function() { this._reportBlur(where); }.bind(this) + }; + + element.addEventListener('focus', monitor.onFocus, true); + element.addEventListener('blur', monitor.onBlur, true); + + if (this.document.activeElement === element) { + this._reportFocus(where); + } + + this._monitoredElements.push(monitor); +}; + +/** + * Undo the effects of addMonitoredElement() + * @param element The element to stop tracking + * @param where Optional source string for debugging only + */ +FocusManager.prototype.removeMonitoredElement = function(element, where) { + if (this.debug) { + console.log('FocusManager.removeMonitoredElement(' + (where || 'unknown') + ')'); + } + + this._monitoredElements = this._monitoredElements.filter(function(monitor) { + if (monitor.element === element) { + element.removeEventListener('focus', monitor.onFocus, true); + element.removeEventListener('blur', monitor.onBlur, true); + return false; + } + return true; + }); +}; + +/** + * Monitor for new command executions + */ +FocusManager.prototype.updatePosition = function(dimensions) { + var ev = { + tooltipVisible: this.isTooltipVisible, + outputVisible: this.isOutputVisible, + dimensions: dimensions + }; + this.onVisibilityChange(ev); +}; + +/** + * Monitor for new command executions + */ +FocusManager.prototype.outputted = function() { + this._recentOutput = true; + this._helpRequested = false; + this._checkShow(); +}; + +/** + * We take a focus event anywhere to be an indication that we might be about + * to lose focus + */ +FocusManager.prototype._focused = function() { + this._reportBlur('document'); +}; + +/** + * Some component has received a 'focus' event. This sets the internal status + * straight away and informs the listeners + * @param where Optional source string for debugging only + */ +FocusManager.prototype._reportFocus = function(where) { + if (this.debug) { + console.log('FocusManager._reportFocus(' + (where || 'unknown') + ')'); + } + + if (this._blurDelayTimeout) { + if (this.debug) { + console.log('FocusManager.cancelBlur'); + } + this.window.clearTimeout(this._blurDelayTimeout); + this._blurDelayTimeout = null; + } + + if (!this._hasFocus) { + this._hasFocus = true; + } + this._checkShow(); +}; + +/** + * Some component has received a 'blur' event. This waits for a while to see if + * we are going to get any subsequent 'focus' events and then sets the internal + * status and informs the listeners + * @param where Optional source string for debugging only + */ +FocusManager.prototype._reportBlur = function(where) { + if (this.debug) { + console.log('FocusManager._reportBlur(' + where + ')'); + } + + if (this._hasFocus) { + if (this._blurDelayTimeout) { + if (this.debug) { + console.log('FocusManager.blurPending'); + } + return; + } + + this._blurDelayTimeout = this.window.setTimeout(function() { + if (this.debug) { + console.log('FocusManager.blur'); + } + this._hasFocus = false; + this._checkShow(); + this._blurDelayTimeout = null; + }.bind(this), this.blurDelay); + } +}; + +/** + * The setting has changed + */ +FocusManager.prototype._eagerHelperChanged = function() { + this._checkShow(); +}; + +/** + * The terminal tells us about keyboard events so we can decide to delay + * showing the tooltip element + */ +FocusManager.prototype.onInputChange = function() { + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Generally called for something like a F1 key press, when the user explicitly + * wants help + */ +FocusManager.prototype.helpRequest = function() { + if (this.debug) { + console.log('FocusManager.helpRequest'); + } + + this._helpRequested = true; + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Generally called for something like a ESC key press, when the user explicitly + * wants to get rid of the help + */ +FocusManager.prototype.removeHelp = function() { + if (this.debug) { + console.log('FocusManager.removeHelp'); + } + + this._importantFieldFlag = false; + this._isError = false; + this._helpRequested = false; + this._recentOutput = false; + this._checkShow(); +}; + +/** + * Set to true whenever a field thinks it's output is important + */ +FocusManager.prototype.setImportantFieldFlag = function(flag) { + if (this.debug) { + console.log('FocusManager.setImportantFieldFlag', flag); + } + this._importantFieldFlag = flag; + this._checkShow(); +}; + +/** + * Set to true whenever a field thinks it's output is important + */ +FocusManager.prototype.setError = function(isError) { + if (this.debug) { + console.log('FocusManager._isError', isError); + } + this._isError = isError; + this._checkShow(); +}; + +/** + * Helper to compare the current showing state with the value calculated by + * _shouldShow() and take appropriate action + */ +FocusManager.prototype._checkShow = function() { + var fire = false; + var ev = { + tooltipVisible: this.isTooltipVisible, + outputVisible: this.isOutputVisible + }; + + var showTooltip = this._shouldShowTooltip(); + if (this.isTooltipVisible !== showTooltip.visible) { + ev.tooltipVisible = this.isTooltipVisible = showTooltip.visible; + fire = true; + } + + var showOutput = this._shouldShowOutput(); + if (this.isOutputVisible !== showOutput.visible) { + ev.outputVisible = this.isOutputVisible = showOutput.visible; + fire = true; + } + + if (fire) { + if (this.debug) { + console.log('FocusManager.onVisibilityChange', ev); + } + this.onVisibilityChange(ev); + } +}; + +/** + * Calculate if we should be showing or hidden taking into account all the + * available inputs + */ +FocusManager.prototype._shouldShowTooltip = function() { + var eagerHelper = this.settings.get('eagerHelper'); + if (eagerHelper.value === Eagerness.NEVER) { + return { visible: false, reason: 'eagerHelperNever' }; + } + + if (eagerHelper.value === Eagerness.ALWAYS) { + return { visible: true, reason: 'eagerHelperAlways' }; + } + + if (!this._hasFocus) { + return { visible: false, reason: 'notHasFocus' }; + } + + if (this._isError) { + return { visible: true, reason: 'isError' }; + } + + if (this._helpRequested) { + return { visible: true, reason: 'helpRequested' }; + } + + if (this._importantFieldFlag) { + return { visible: true, reason: 'importantFieldFlag' }; + } + + return { visible: false, reason: 'default' }; +}; + +/** + * Calculate if we should be showing or hidden taking into account all the + * available inputs + */ +FocusManager.prototype._shouldShowOutput = function() { + if (!this._hasFocus) { + return { visible: false, reason: 'notHasFocus' }; + } + + if (this._recentOutput) { + return { visible: true, reason: 'recentOutput' }; + } + + return { visible: false, reason: 'default' }; +}; + +exports.FocusManager = FocusManager; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/history.js b/devtools/shared/gcli/source/lib/gcli/ui/history.js new file mode 100644 index 000000000..a9d4b868c --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/history.js @@ -0,0 +1,71 @@ +/* + * 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'; + +/** + * A History object remembers commands that have been entered in the past and + * provides an API for accessing them again. + * See Bug 681340: Search through history (like C-r in bash)? + */ +function History() { + // This is the actual buffer where previous commands are kept. + // 'this._buffer[0]' should always be equal the empty string. This is so + // that when you try to go in to the "future", you will just get an empty + // command. + this._buffer = ['']; + + // This is an index in to the history buffer which points to where we + // currently are in the history. + this._current = 0; +} + +/** + * Avoid memory leaks + */ +History.prototype.destroy = function() { + this._buffer = undefined; +}; + +/** + * Record and save a new command in the history. + */ +History.prototype.add = function(command) { + this._buffer.splice(1, 0, command); + this._current = 0; +}; + +/** + * Get the next (newer) command from history. + */ +History.prototype.forward = function() { + if (this._current > 0 ) { + this._current--; + } + return this._buffer[this._current]; +}; + +/** + * Get the previous (older) item from history. + */ +History.prototype.backward = function() { + if (this._current < this._buffer.length - 1) { + this._current++; + } + return this._buffer[this._current]; +}; + +exports.History = History; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/intro.js b/devtools/shared/gcli/source/lib/gcli/ui/intro.js new file mode 100644 index 000000000..9abf51db6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/intro.js @@ -0,0 +1,90 @@ +/* + * 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 l10n = require('../util/l10n'); +var Output = require('../cli').Output; +var view = require('./view'); + +/** + * Record if the user has clicked on 'Got It!' + */ +exports.items = [ + { + item: 'setting', + name: 'hideIntro', + type: 'boolean', + description: l10n.lookup('hideIntroDesc'), + defaultValue: false + } +]; + +/** + * Called when the UI is ready to add a welcome message to the output + */ +exports.maybeShowIntro = function (commandOutputManager, conversionContext, + outputPanel) { + var hideIntro = conversionContext.system.settings.get('hideIntro'); + if (hideIntro.value) { + return; + } + + var output = new Output(conversionContext); + output.type = 'view'; + commandOutputManager.onOutput({ output: output }); + + var viewData = this.createView(null, conversionContext, true, outputPanel); + + output.complete({ isTypedData: true, type: 'view', data: viewData }); +}; + +/** + * Called when the UI is ready to add a welcome message to the output + */ +exports.createView = function (ignoreArgs, conversionContext, showHideButton, + outputPanel) { + return view.createView({ + html: + '<div save="${mainDiv}">\n' + + ' <p>${l10n.introTextOpening3}</p>\n' + + '\n' + + ' <p>\n' + + ' ${l10n.introTextCommands}\n' + + ' <span class="gcli-out-shortcut" onclick="${onclick}"\n' + + ' ondblclick="${ondblclick}"\n' + + ' data-command="help">help</span>${l10n.introTextKeys2}\n' + + ' <code>${l10n.introTextF1Escape}</code>.\n' + + ' </p>\n' + + '\n' + + ' <button onclick="${onGotIt}"\n' + + ' if="${showHideButton}">${l10n.introTextGo}</button>\n' + + '</div>', + options: { stack: 'intro.html' }, + data: { + l10n: l10n.propertyLookup, + onclick: conversionContext.update, + ondblclick: conversionContext.updateExec, + showHideButton: showHideButton, + onGotIt: function(ev) { + var settings = conversionContext.system.settings; + var hideIntro = settings.get('hideIntro'); + hideIntro.value = true; + outputPanel.remove(); + } + } + }); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.css b/devtools/shared/gcli/source/lib/gcli/ui/menu.css new file mode 100644 index 000000000..913ee1eec --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.css @@ -0,0 +1,69 @@ + +.gcli-menu { + overflow: hidden; + font-size: 90%; +} + +.gcli-menu:not(:first-of-type) { + padding-top: 5px; +} + +.gcli-menu-vert { + white-space: nowrap; + max-width: 22em; + display: inline-flex; + padding-inline-end: 20px; + -webkit-padding-end: 20px; +} + +.gcli-menu-names { + white-space: nowrap; + flex-grow: 0; + flex-shrink: 0; +} + +.gcli-menu-descs { + flex-grow: 1; + flex-shrink: 1; +} + +.gcli-menu-name, +.gcli-menu-desc { + white-space: nowrap; +} + +.gcli-menu-name { + padding-inline-start: 2px; + -webkit-padding-start: 2px; + padding-inline-end: 8px; + -webkit-padding-end: 8px; +} + +.gcli-menu-desc { + padding-inline-end: 2px; + -webkit-padding-end: 2px; + color: #777; + text-overflow: ellipsis; + overflow: hidden; +} + +.gcli-menu-name:hover, +.gcli-menu-desc:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.gcli-menu-highlight, +.gcli-menu-highlight.gcli-menu-option:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.gcli-menu-typed { + color: #FF6600; +} + +.gcli-menu-more { + font-size: 80%; + width: 8em; + display: inline-flex; + vertical-align: bottom; +} diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.html b/devtools/shared/gcli/source/lib/gcli/ui/menu.html new file mode 100644 index 000000000..ab6a690f4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.html @@ -0,0 +1,20 @@ + +<div> + <div class="gcli-menu-template" aria-live="polite"> + <div class="gcli-menu-names"> + <div class="gcli-menu-name" + foreach="item in ${items}" + data-name="${item.name}" + onclick="${onItemClickInternal}" + title="${item.manual}">${item.highlight}</div> + </div> + <div class="gcli-menu-descs"> + <div class="gcli-menu-desc" + foreach="item in ${items}" + data-name="${item.name}" + onclick="${onItemClickInternal}" + title="${item.manual}">${item.description}</div> + </div> + </div> + <div class="gcli-menu-more" if="${hasMore}">${l10n.fieldMenuMore}</div> +</div> diff --git a/devtools/shared/gcli/source/lib/gcli/ui/menu.js b/devtools/shared/gcli/source/lib/gcli/ui/menu.js new file mode 100644 index 000000000..52b415384 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/menu.js @@ -0,0 +1,328 @@ +/* + * 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'); +var domtemplate = require('../util/domtemplate'); +var host = require('../util/host'); + +/** + * Shared promises for loading resource files + */ +var menuCssPromise; +var menuHtmlPromise; + +/** + * Menu is a display of the commands that are possible given the state of a + * requisition. + * @param options A way to customize the menu display. + * - document: The document to use in creating widgets + * - maxPredictions (default=8): The maximum predictions to show at one time + * If more are requested, a message will be displayed asking the user to + * continue typing to narrow the list of options + */ +function Menu(options) { + options = options || {}; + this.document = options.document || document; + this.maxPredictions = options.maxPredictions || 8; + + // Keep track of any highlighted items + this._choice = null; + + // FF can be really hard to debug if doc is null, so we check early on + if (!this.document) { + throw new Error('No document'); + } + + this.element = util.createElement(this.document, 'div'); + this.element.classList.add('gcli-menu'); + + if (menuCssPromise == null) { + menuCssPromise = host.staticRequire(module, './menu.css'); + } + menuCssPromise.then(function(menuCss) { + // Pull the HTML into the DOM, but don't add it to the document + if (menuCss != null) { + util.importCss(menuCss, this.document, 'gcli-menu'); + } + }.bind(this), console.error); + + this.templateOptions = { blankNullUndefined: true, stack: 'menu.html' }; + if (menuHtmlPromise == null) { + menuHtmlPromise = host.staticRequire(module, './menu.html'); + } + menuHtmlPromise.then(function(menuHtml) { + if (this.document == null) { + return; // destroy() has been called + } + + this.template = host.toDom(this.document, menuHtml); + }.bind(this), console.error); + + // Contains the items that should be displayed + this.items = []; + + this.onItemClick = util.createEvent('Menu.onItemClick'); +} + +/** + * Allow the template engine to get at localization strings + */ +Menu.prototype.l10n = l10n.propertyLookup; + +/** + * Avoid memory leaks + */ +Menu.prototype.destroy = function() { + this.element = undefined; + this.template = undefined; + this.document = undefined; + this.items = undefined; +}; + +/** + * The default is to do nothing when someone clicks on the menu. + * This is called from template.html + * @param ev The click event from the browser + */ +Menu.prototype.onItemClickInternal = function(ev) { + var name = ev.currentTarget.getAttribute('data-name'); + if (!name) { + var named = ev.currentTarget.querySelector('[data-name]'); + name = named.getAttribute('data-name'); + } + this.onItemClick({ name: name }); +}; + +/** + * Act as though someone clicked on the selected item + */ +Menu.prototype.clickSelected = function() { + this.onItemClick({ name: this.selected }); +}; + +/** + * What is the currently selected item? + */ +Object.defineProperty(Menu.prototype, 'isSelected', { + get: function() { + return this.selected != null; + }, + enumerable: true +}); + +/** + * What is the currently selected item? + */ +Object.defineProperty(Menu.prototype, 'selected', { + get: function() { + var item = this.element.querySelector('.gcli-menu-name.gcli-menu-highlight'); + if (!item) { + return null; + } + return item.textContent; + }, + enumerable: true +}); + +/** + * Display a number of items in the menu (or hide the menu if there is nothing + * to display) + * @param items The items to show in the menu + * @param match Matching text to highlight in the output + */ +Menu.prototype.show = function(items, match) { + // If the HTML hasn't loaded yet then just don't show a menu + if (this.template == null) { + return; + } + + this.items = items.filter(function(item) { + return item.hidden === undefined || item.hidden !== true; + }.bind(this)); + + this.items = this.items.map(function(item) { + return getHighlightingProxy(item, match, this.template.ownerDocument); + }.bind(this)); + + if (this.items.length === 0) { + this.element.style.display = 'none'; + return; + } + + if (this.items.length >= this.maxPredictions) { + this.items.splice(-1); + this.hasMore = true; + } + else { + this.hasMore = false; + } + + var options = this.template.cloneNode(true); + domtemplate.template(options, this, this.templateOptions); + + util.clearElement(this.element); + this.element.appendChild(options); + + this.element.style.display = 'block'; +}; + +var MAX_ITEMS = 3; + +/** + * Takes an array of items and cuts it into an array of arrays to help us + * to place the items into columns. + * The inner arrays will have at most MAX_ITEMS in them, with the number of + * outer arrays expanding to accommodate. + */ +Object.defineProperty(Menu.prototype, 'itemsSubdivided', { + get: function() { + var reply = []; + + var taken = 0; + while (taken < this.items.length) { + reply.push(this.items.slice(taken, taken + MAX_ITEMS)); + taken += MAX_ITEMS; + } + + return reply; + }, + enumerable: true +}); + +/** + * Create a proxy around an item that highlights matching text + */ +function getHighlightingProxy(item, match, document) { + var proxy = {}; + Object.defineProperties(proxy, { + highlight: { + get: function() { + if (!match) { + return item.name; + } + + var value = item.name; + var startMatch = value.indexOf(match); + if (startMatch === -1) { + return value; + } + + var before = value.substr(0, startMatch); + var after = value.substr(startMatch + match.length); + var parent = util.createElement(document, 'span'); + parent.appendChild(document.createTextNode(before)); + var highlight = util.createElement(document, 'span'); + highlight.classList.add('gcli-menu-typed'); + highlight.appendChild(document.createTextNode(match)); + parent.appendChild(highlight); + parent.appendChild(document.createTextNode(after)); + return parent; + }, + enumerable: true + }, + + name: { + value: item.name, + enumerable: true + }, + + manual: { + value: item.manual, + enumerable: true + }, + + description: { + value: item.description, + enumerable: true + } + }); + return proxy; +} + +/** + * @return {int} current choice index + */ +Menu.prototype.getChoiceIndex = function() { + return this._choice == null ? 0 : this._choice; +}; + +/** + * Highlight the next (for by=1) or previous (for by=-1) option + */ +Menu.prototype.nudgeChoice = function(by) { + if (this._choice == null) { + this._choice = 0; + } + + // There's an annoying up is down thing here, the menu is presented + // with the zeroth index at the top working down, so the UP arrow needs + // pick the choice below because we're working down + this._choice -= by; + this._updateHighlight(); +}; + +/** + * Highlight nothing + */ +Menu.prototype.unsetChoice = function() { + this._choice = null; + this._updateHighlight(); +}; + +/** + * Internal option to update the currently highlighted option + */ +Menu.prototype._updateHighlight = function() { + var names = this.element.querySelectorAll('.gcli-menu-name'); + var descs = this.element.querySelectorAll('.gcli-menu-desc'); + for (var i = 0; i < names.length; i++) { + names[i].classList.remove('gcli-menu-highlight'); + } + for (i = 0; i < descs.length; i++) { + descs[i].classList.remove('gcli-menu-highlight'); + } + + if (this._choice == null || names.length === 0) { + return; + } + + var index = this._choice % names.length; + if (index < 0) { + index = names.length + index; + } + + names.item(index).classList.add('gcli-menu-highlight'); + descs.item(index).classList.add('gcli-menu-highlight'); +}; + +/** + * Hide the menu + */ +Menu.prototype.hide = function() { + this.element.style.display = 'none'; +}; + +/** + * Change how much vertical space this menu can take up + */ +Menu.prototype.setMaxHeight = function(height) { + this.element.style.maxHeight = height + 'px'; +}; + +exports.Menu = Menu; diff --git a/devtools/shared/gcli/source/lib/gcli/ui/moz.build b/devtools/shared/gcli/source/lib/gcli/ui/moz.build new file mode 100644 index 000000000..70ac666f0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/moz.build @@ -0,0 +1,15 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'focus.js', + 'history.js', + 'intro.js', + 'menu.css', + 'menu.html', + 'menu.js', + 'view.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/ui/view.js b/devtools/shared/gcli/source/lib/gcli/ui/view.js new file mode 100644 index 000000000..193fb2d96 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/ui/view.js @@ -0,0 +1,87 @@ +/* + * 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 host = require('../util/host'); +var domtemplate = require('../util/domtemplate'); + + +/** + * We want to avoid commands having to create DOM structures because that's + * messy and because we're going to need to have command output displayed in + * different documents. A View is a way to wrap an HTML template (for + * domtemplate) in with the data and options to render the template, so anyone + * can later run the template in the context of any document. + * View also cuts out a chunk of boiler place code. + * @param options The information needed to create the DOM from HTML. Includes: + * - html (required): The HTML source, probably from a call to require + * - options (default={}): The domtemplate options. See domtemplate for details + * - data (default={}): The data to domtemplate. See domtemplate for details. + * - css (default=none): Some CSS to be added to the final document. If 'css' + * is used, use of cssId is strongly recommended. + * - cssId (default=none): An ID to prevent multiple CSS additions. See + * util.importCss for more details. + * @return An object containing a single function 'appendTo()' which runs the + * template adding the result to the specified element. Takes 2 parameters: + * - element (required): the element to add to + * - clear (default=false): if clear===true then remove all pre-existing + * children of 'element' before appending the results of this template. + */ +exports.createView = function(options) { + if (options.html == null) { + throw new Error('options.html is missing'); + } + + return { + /** + * RTTI. Yeah. + */ + isView: true, + + /** + * Run the template against the document to which element belongs. + * @param element The element to append the result to + * @param clear Set clear===true to remove all children of element + */ + appendTo: function(element, clear) { + // Strict check on the off-chance that we later think of other options + // and want to replace 'clear' with an 'options' parameter, but want to + // support backwards compat. + if (clear === true) { + util.clearElement(element); + } + + element.appendChild(this.toDom(element.ownerDocument)); + }, + + /** + * Actually convert the view data into a DOM suitable to be appended to + * an element + * @param document to use in realizing the template + */ + toDom: function(document) { + if (options.css) { + util.importCss(options.css, document, options.cssId); + } + + var child = host.toDom(document, options.html); + domtemplate.template(child, options.data || {}, options.options || {}); + return child; + } + }; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js b/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js new file mode 100644 index 000000000..d8979db3b --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/domtemplate.js @@ -0,0 +1,20 @@ +/* + * 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 {template} = require("devtools/shared/gcli/templater"); +exports.template = template; diff --git a/devtools/shared/gcli/source/lib/gcli/util/fileparser.js b/devtools/shared/gcli/source/lib/gcli/util/fileparser.js new file mode 100644 index 000000000..4c470e638 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/fileparser.js @@ -0,0 +1,281 @@ +/* + * 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'); +var l10n = require('./l10n'); +var spell = require('./spell'); +var filesystem = require('./filesystem'); +var Status = require('../types/types').Status; + +/* + * An implementation of the functions that call the filesystem, designed to + * support the file type. + */ + +/** + * Helper for the parse() function from the file type. + * See gcli/util/filesystem.js for details + */ +exports.parse = function(context, typed, options) { + return filesystem.stat(typed).then(function(stats) { + // The 'save-as' case - the path should not exist but does + if (options.existing === 'no' && stats.exists) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrExists', [ typed ]), + predictor: undefined // No predictions that we can give here + }; + } + + if (stats.exists) { + // The path exists - check it's the correct file type ... + if (options.filetype === 'file' && !stats.isFile) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrIsNotFile', [ typed ]), + predictor: getPredictor(typed, options) + }; + } + + if (options.filetype === 'directory' && !stats.isDir) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrIsNotDirectory', [ typed ]), + predictor: getPredictor(typed, options) + }; + } + + // ... and that it matches any 'match' RegExp + if (options.matches != null && !options.matches.test(typed)) { + return { + value: undefined, + status: Status.INCOMPLETE, + message: l10n.lookupFormat('fileErrDoesntMatch', + [ typed, options.source ]), + predictor: getPredictor(typed, options) + }; + } + } + else { + if (options.existing === 'yes') { + // We wanted something that exists, but it doesn't. But we don't know + // if the path so far is an ERROR or just INCOMPLETE + var parentName = filesystem.dirname(typed); + return filesystem.stat(parentName).then(function(stats) { + return { + value: undefined, + status: stats.isDir ? Status.INCOMPLETE : Status.ERROR, + message: l10n.lookupFormat('fileErrNotExists', [ typed ]), + predictor: getPredictor(typed, options) + }; + }); + } + } + + // We found no problems + return { + value: typed, + status: Status.VALID, + message: undefined, + predictor: getPredictor(typed, options) + }; + }); +}; + +var RANK_OPTIONS = { noSort: true, prefixZero: true }; + +/** + * We want to be able to turn predictions off in Firefox + */ +exports.supportsPredictions = false; + +/** + * Get a function which creates predictions of files that match the given + * path + */ +function getPredictor(typed, options) { + if (!exports.supportsPredictions) { + return undefined; + } + + return function() { + var allowFile = (options.filetype !== 'directory'); + var parts = filesystem.split(typed); + + var absolute = (typed.indexOf('/') === 0); + var roots; + if (absolute) { + roots = [ { name: '/', dist: 0, original: '/' } ]; + } + else { + roots = dirHistory.getCommonDirectories().map(function(root) { + return { name: root, dist: 0, original: root }; + }); + } + + // Add each part of the typed pathname onto each of the roots in turn, + // Finding options from each of those paths, and using these options as + // our roots for the next part + var partsAdded = util.promiseEach(parts, function(part, index) { + + var partsSoFar = filesystem.join.apply(filesystem, parts.slice(0, index + 1)); + + // We allow this file matches in this pass if we're allowed files at all + // (i.e this isn't 'cd') and if this is the last part of the path + var allowFileForPart = (allowFile && index >= parts.length - 1); + + var rootsPromise = util.promiseEach(roots, function(root) { + + // Extend each roots to a list of all the files in each of the roots + var matchFile = allowFileForPart ? options.matches : null; + var promise = filesystem.ls(root.name, matchFile); + + var onSuccess = function(entries) { + // Unless this is the final part filter out the non-directories + if (!allowFileForPart) { + entries = entries.filter(function(entry) { + return entry.isDir; + }); + } + var entryMap = {}; + entries.forEach(function(entry) { + entryMap[entry.pathname] = entry; + }); + return entryMap; + }; + + var onError = function(err) { + // We expect errors due to the path not being a directory, not being + // accessible, or removed since the call to 'readdir' + return {}; + }; + + promise = promise.then(onSuccess, onError); + + // We want to compare all the directory entries with the original root + // plus the partsSoFar + var compare = filesystem.join(root.original, partsSoFar); + + return promise.then(function(entryMap) { + + var ranks = spell.rank(compare, Object.keys(entryMap), RANK_OPTIONS); + // penalize each path by the distance of it's parent + ranks.forEach(function(rank) { + rank.original = root.original; + rank.stats = entryMap[rank.name]; + }); + return ranks; + }); + }); + + return rootsPromise.then(function(data) { + // data is an array of arrays of ranking objects. Squash down. + data = data.reduce(function(prev, curr) { + return prev.concat(curr); + }, []); + + data.sort(function(r1, r2) { + return r1.dist - r2.dist; + }); + + // Trim, but by how many? + // If this is the last run through, we want to present the user with + // a sensible set of predictions. Otherwise we want to trim the tree + // to a reasonable set of matches, so we're happy with 1 + // We look through x +/- 3 roots, and find the one with the biggest + // distance delta, and cut below that + // x=5 for the last time through, and x=8 otherwise + var isLast = index >= parts.length - 1; + var start = isLast ? 1 : 5; + var end = isLast ? 7 : 10; + + var maxDeltaAt = start; + var maxDelta = data[start].dist - data[start - 1].dist; + + for (var i = start + 1; i < end; i++) { + var delta = data[i].dist - data[i - 1].dist; + if (delta >= maxDelta) { + maxDelta = delta; + maxDeltaAt = i; + } + } + + // Update the list of roots for the next time round + roots = data.slice(0, maxDeltaAt); + }); + }); + + return partsAdded.then(function() { + var predictions = roots.map(function(root) { + var isFile = root.stats && root.stats.isFile; + var isDir = root.stats && root.stats.isDir; + + var name = root.name; + if (isDir && name.charAt(name.length) !== filesystem.sep) { + name += filesystem.sep; + } + + return { + name: name, + incomplete: !(allowFile && isFile), + isFile: isFile, // Added for describe, below + dist: root.dist, // TODO: Remove - added for debug in describe + }; + }); + + return util.promiseEach(predictions, function(prediction) { + if (!prediction.isFile) { + prediction.description = '(' + prediction.dist + ')'; + prediction.dist = undefined; + prediction.isFile = undefined; + return prediction; + } + + return filesystem.describe(prediction.name).then(function(description) { + prediction.description = description; + prediction.dist = undefined; + prediction.isFile = undefined; + return prediction; + }); + }); + }); + }; +} + +// ============================================================================= + +/* + * The idea is that we maintain a list of 'directories that the user is + * interested in'. We store directories in a most-frequently-used cache + * of some description. + * But for now we're just using / and ~/ + */ +var dirHistory = { + getCommonDirectories: function() { + return [ + filesystem.sep, // i.e. the root directory + filesystem.home // i.e. the users home directory + ]; + }, + addCommonDirectory: function(ignore) { + // Not implemented yet + } +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/filesystem.js b/devtools/shared/gcli/source/lib/gcli/util/filesystem.js new file mode 100644 index 000000000..a7b22a8f7 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/filesystem.js @@ -0,0 +1,130 @@ +/* + * 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 Cu = require('chrome').Cu; +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; + +var OS = Cu.import('resource://gre/modules/osfile.jsm', {}).OS; + +/** + * A set of functions that don't really belong in 'fs' (because they're not + * really universal in scope) but also kind of do (because they're not specific + * to GCLI + */ + +exports.join = OS.Path.join; +exports.sep = OS.Path.sep; +exports.dirname = OS.Path.dirname; + +// On B2G, there is no home folder +var home = null; +try { + var dirService = Cc['@mozilla.org/file/directory_service;1'] + .getService(Ci.nsIProperties); + home = dirService.get('Home', Ci.nsIFile).path; +} catch(e) {} +exports.home = home; + +if ('winGetDrive' in OS.Path) { + exports.sep = '\\'; +} +else { + exports.sep = '/'; +} + +/** + * Split a path into its components. + * @param pathname (string) The part to cut up + * @return An array of path components + */ +exports.split = function(pathname) { + return OS.Path.split(pathname).components; +}; + +/** + * @param pathname string, path of an existing directory + * @param matches optional regular expression - filter output to include only + * the files that match the regular expression. The regexp is applied to the + * filename only not to the full path + * @return A promise of an array of stat objects for each member of the + * directory pointed to by ``pathname``, each containing 2 extra properties: + * - pathname: The full pathname of the file + * - filename: The final filename part of the pathname + */ +exports.ls = function(pathname, matches) { + var iterator = new OS.File.DirectoryIterator(pathname); + var entries = []; + + var iteratePromise = iterator.forEach(function(entry) { + entries.push({ + exists: true, + isDir: entry.isDir, + isFile: !entry.isFile, + filename: entry.name, + pathname: entry.path + }); + }); + + return iteratePromise.then(function onSuccess() { + iterator.close(); + return entries; + }, + function onFailure(reason) { + iterator.close(); + throw reason; + } + ); +}; + +/** + * stat() is annoying because it considers stat('/doesnt/exist') to be an + * error, when the point of stat() is to *find* *out*. So this wrapper just + * converts 'ENOENT' i.e. doesn't exist to { exists:false } and adds + * exists:true to stat blocks from existing paths + */ +exports.stat = function(pathname) { + var onResolve = function(stats) { + return { + exists: true, + isDir: stats.isDir, + isFile: !stats.isFile + }; + }; + + var onReject = function(err) { + if (err instanceof OS.File.Error && err.becauseNoSuchFile) { + return { + exists: false, + isDir: false, + isFile: false + }; + } + throw err; + }; + + return OS.File.stat(pathname).then(onResolve, onReject); +}; + +/** + * We may read the first line of a file to describe it? + * Right now, however, we do nothing. + */ +exports.describe = function(pathname) { + return Promise.resolve(''); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/host.js b/devtools/shared/gcli/source/lib/gcli/util/host.js new file mode 100644 index 000000000..00fefa4f6 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/host.js @@ -0,0 +1,230 @@ +/* + * 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 Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; + +var { Task } = require("devtools/shared/task"); + +var util = require('./util'); + +function Highlighter(document) { + this._document = document; + this._nodes = util.createEmptyNodeList(this._document); +} + +Object.defineProperty(Highlighter.prototype, 'nodelist', { + set: function(nodes) { + Array.prototype.forEach.call(this._nodes, this._unhighlightNode, this); + this._nodes = (nodes == null) ? + util.createEmptyNodeList(this._document) : + nodes; + Array.prototype.forEach.call(this._nodes, this._highlightNode, this); + }, + get: function() { + return this._nodes; + }, + enumerable: true +}); + +Highlighter.prototype.destroy = function() { + this.nodelist = null; +}; + +Highlighter.prototype._highlightNode = function(node) { + // Enable when the highlighter rewrite is done +}; + +Highlighter.prototype._unhighlightNode = function(node) { + // Enable when the highlighter rewrite is done +}; + +exports.Highlighter = Highlighter; + +/** + * See docs in lib/gcli/util/host.js + */ +exports.exec = function(task) { + return Task.spawn(task); +}; + +/** + * The URL API is new enough that we need specific platform help + */ +exports.createUrl = function(uristr, base) { + return new URL(uristr, base); +}; + +/** + * Load some HTML into the given document and return a DOM element. + * This utility assumes that the html has a single root (other than whitespace) + */ +exports.toDom = function(document, html) { + var div = util.createElement(document, 'div'); + util.setContents(div, html); + return div.children[0]; +}; + +/** + * When dealing with module paths on windows we want to use the unix + * directory separator rather than the windows one, so we avoid using + * OS.Path.dirname, and use unix version on all platforms. + */ +var resourceDirName = function(path) { + var index = path.lastIndexOf('/'); + if (index == -1) { + return '.'; + } + while (index >= 0 && path[index] == '/') { + --index; + } + return path.slice(0, index + 1); +}; + +/** + * Asynchronously load a text resource + * @see lib/gcli/util/host.js + */ +exports.staticRequire = function(requistingModule, name) { + if (name.match(/\.css$/)) { + return Promise.resolve(''); + } + else { + return new Promise(function(resolve, reject) { + var filename = resourceDirName(requistingModule.id) + '/' + name; + filename = filename.replace(/\/\.\//g, '/'); + filename = 'resource://devtools/shared/gcli/source/lib/' + filename; + + var xhr = Cc['@mozilla.org/xmlextras/xmlhttprequest;1'] + .createInstance(Ci.nsIXMLHttpRequest); + + xhr.onload = function onload() { + resolve(xhr.responseText); + }.bind(this); + + xhr.onabort = xhr.onerror = xhr.ontimeout = function(err) { + reject(err); + }.bind(this); + + xhr.open('GET', filename); + xhr.send(); + }.bind(this)); + } +}; + +/** + * A group of functions to help scripting. Small enough that it doesn't need + * a separate module (it's basically a wrapper around 'eval' in some contexts) + */ +var client; +var target; +var consoleActor; +var webConsoleClient; + +exports.script = { }; + +exports.script.onOutput = util.createEvent('Script.onOutput'); + +/** + * Setup the environment to eval JavaScript + */ +exports.script.useTarget = function(tgt) { + target = tgt; + + // Local debugging needs to make the target remote. + var targetPromise = target.isRemote ? + Promise.resolve(target) : + target.makeRemote(); + + return targetPromise.then(function() { + return new Promise(function(resolve, reject) { + client = target._client; + + client.addListener('pageError', function(packet) { + if (packet.from === consoleActor) { + // console.log('pageError', packet.pageError); + exports.script.onOutput({ + level: 'exception', + message: packet.exception.class + }); + } + }); + + client.addListener('consoleAPICall', function(type, packet) { + if (packet.from === consoleActor) { + var data = packet.message; + + var ev = { + level: data.level, + arguments: data.arguments, + }; + + if (data.filename !== 'debugger eval code') { + ev.source = { + filename: data.filename, + lineNumber: data.lineNumber, + functionName: data.functionName + }; + } + + exports.script.onOutput(ev); + } + }); + + consoleActor = target._form.consoleActor; + + var onAttach = function(response, wcc) { + webConsoleClient = wcc; + + if (response.error != null) { + reject(response); + } + else { + resolve(response); + } + + // TODO: add _onTabNavigated code? + }; + + var listeners = [ 'PageError', 'ConsoleAPI' ]; + client.attachConsole(consoleActor, listeners, onAttach); + }.bind(this)); + }); +}; + +/** + * Execute some JavaScript + */ +exports.script.evaluate = function(javascript) { + return new Promise(function(resolve, reject) { + var onResult = function(response) { + var output = response.result; + if (typeof output === 'object' && output.type === 'undefined') { + output = undefined; + } + + resolve({ + input: response.input, + output: output, + exception: response.exception + }); + }; + + webConsoleClient.evaluateJS(javascript, onResult, {}); + }.bind(this)); +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/l10n.js b/devtools/shared/gcli/source/lib/gcli/util/l10n.js new file mode 100644 index 000000000..6d0c7c8f4 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/l10n.js @@ -0,0 +1,80 @@ +/* + * 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"; + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/shared/locales/gcli.properties"); + +/* + * Not supported when embedded - we"re doing things the Mozilla way not the + * require.js way. + */ +exports.registerStringsSource = function (modulePath) { + throw new Error("registerStringsSource is not available in mozilla"); +}; + +exports.unregisterStringsSource = function (modulePath) { + throw new Error("unregisterStringsSource is not available in mozilla"); +}; + +exports.lookupSwap = function (key, swaps) { + throw new Error("lookupSwap is not available in mozilla"); +}; + +exports.lookupPlural = function (key, ord, swaps) { + throw new Error("lookupPlural is not available in mozilla"); +}; + +exports.getPreferredLocales = function () { + return [ "root" ]; +}; + +/** @see lookup() in lib/gcli/util/l10n.js */ +exports.lookup = function (key) { + try { + // Our memory leak hunter walks reachable objects trying to work out what + // type of thing they are using object.constructor.name. If that causes + // problems then we can avoid the unknown-key-exception with the following: + /* + if (key === "constructor") { + return { name: "l10n-mem-leak-defeat" }; + } + */ + + return L10N.getStr(key); + } catch (ex) { + console.error("Failed to lookup ", key, ex); + return key; + } +}; + +/** @see propertyLookup in lib/gcli/util/l10n.js */ +exports.propertyLookup = new Proxy({}, { + get: function (rcvr, name) { + return exports.lookup(name); + } +}); + +/** @see lookupFormat in lib/gcli/util/l10n.js */ +exports.lookupFormat = function (key, swaps) { + try { + return L10N.getFormatStr(key, ...swaps); + } catch (ex) { + console.error("Failed to format ", key, ex); + return key; + } +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/legacy.js b/devtools/shared/gcli/source/lib/gcli/util/legacy.js new file mode 100644 index 000000000..07b0fd71a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/legacy.js @@ -0,0 +1,147 @@ +/* + * 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'; + +/** + * Fake a console for IE9 + */ +if (typeof window !== 'undefined' && window.console == null) { + window.console = {}; +} +'debug,log,warn,error,trace,group,groupEnd'.split(',').forEach(function(f) { + if (typeof window !== 'undefined' && !window.console[f]) { + window.console[f] = function() {}; + } +}); + +/** + * Fake Element.classList for IE9 + * Based on https://gist.github.com/1381839 by Devon Govett + */ +if (typeof document !== 'undefined' && typeof HTMLElement !== 'undefined' && + !('classList' in document.documentElement) && Object.defineProperty) { + Object.defineProperty(HTMLElement.prototype, 'classList', { + get: function() { + var self = this; + function update(fn) { + return function(value) { + var classes = self.className.split(/\s+/); + var index = classes.indexOf(value); + fn(classes, index, value); + self.className = classes.join(' '); + }; + } + + var ret = { + add: update(function(classes, index, value) { + ~index || classes.push(value); + }), + remove: update(function(classes, index) { + ~index && classes.splice(index, 1); + }), + toggle: update(function(classes, index, value) { + ~index ? classes.splice(index, 1) : classes.push(value); + }), + contains: function(value) { + return !!~self.className.split(/\s+/).indexOf(value); + }, + item: function(i) { + return self.className.split(/\s+/)[i] || null; + } + }; + + Object.defineProperty(ret, 'length', { + get: function() { + return self.className.split(/\s+/).length; + } + }); + + return ret; + } + }); +} + +/** + * Array.find + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find + */ +if (!Array.prototype.find) { + Object.defineProperty(Array.prototype, 'find', { + enumerable: false, + configurable: true, + writable: true, + value: function(predicate) { + if (this == null) { + throw new TypeError('Array.prototype.find called on null or undefined'); + } + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + var list = Object(this); + var length = list.length >>> 0; + var thisArg = arguments[1]; + var value; + + for (var i = 0; i < length; i++) { + if (i in list) { + value = list[i]; + if (predicate.call(thisArg, value, i, list)) { + return value; + } + } + } + return undefined; + } + }); +} + +/** + * String.prototype.trimLeft is non-standard, but it works in Firefox, + * Chrome and Opera. It's easiest to create a shim here. + */ +if (!String.prototype.trimLeft) { + String.prototype.trimLeft = function() { + return String(this).replace(/\s*$/, ''); + }; +} + +/** + * Polyfil taken from + * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind + */ +if (!Function.prototype.bind) { + Function.prototype.bind = function(oThis) { + if (typeof this !== 'function') { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable'); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + return fBound; + }; +} diff --git a/devtools/shared/gcli/source/lib/gcli/util/moz.build b/devtools/shared/gcli/source/lib/gcli/util/moz.build new file mode 100644 index 000000000..0fdeb96ec --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/moz.build @@ -0,0 +1,17 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + 'domtemplate.js', + 'fileparser.js', + 'filesystem.js', + 'host.js', + 'l10n.js', + 'legacy.js', + 'prism.js', + 'spell.js', + 'util.js', +) diff --git a/devtools/shared/gcli/source/lib/gcli/util/prism.js b/devtools/shared/gcli/source/lib/gcli/util/prism.js new file mode 100644 index 000000000..6f457cf23 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/prism.js @@ -0,0 +1,361 @@ +/** + * Prism: Lightweight, robust, elegant syntax highlighting + * MIT license http://www.opensource.org/licenses/mit-license.php/ + * @author Lea Verou http://lea.verou.me + */ + +'use strict'; + +// Private helper vars +var lang = /\blang(?:uage)?-(?!\*)(\w+)\b/i; + +var Prism = exports.Prism = { + util: { + type: function (o) { + return Object.prototype.toString.call(o).match(/\[object (\w+)\]/)[1]; + }, + + // Deep clone a language definition (e.g. to extend it) + clone: function (o) { + var type = Prism.util.type(o); + + switch (type) { + case 'Object': + var clone = {}; + + for (var key in o) { + if (o.hasOwnProperty(key)) { + clone[key] = Prism.util.clone(o[key]); + } + } + + return clone; + + case 'Array': + return o.slice(); + } + + return o; + } + }, + + languages: { + extend: function (id, redef) { + var lang = Prism.util.clone(Prism.languages[id]); + + for (var key in redef) { + lang[key] = redef[key]; + } + + return lang; + }, + + // Insert a token before another token in a language literal + insertBefore: function (inside, before, insert, root) { + root = root || Prism.languages; + var grammar = root[inside]; + var ret = {}; + + for (var token in grammar) { + + if (grammar.hasOwnProperty(token)) { + + if (token == before) { + + for (var newToken in insert) { + + if (insert.hasOwnProperty(newToken)) { + ret[newToken] = insert[newToken]; + } + } + } + + ret[token] = grammar[token]; + } + } + + root[inside] = ret; + return ret; + }, + + // Traverse a language definition with Depth First Search + DFS: function(o, callback) { + for (var i in o) { + callback.call(o, i, o[i]); + + if (Prism.util.type(o) === 'Object') { + Prism.languages.DFS(o[i], callback); + } + } + } + }, + + highlightAll: function(async, callback) { + var elements = document.querySelectorAll('code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'); + + elements.forEach(function(element) { + Prism.highlightElement(element, async === true, callback); + }); + }, + + highlightElement: function(element, async, callback) { + // Find language + var language; + var grammar; + + var parent = element; + while (parent && !lang.test(parent.className)) { + parent = parent.parentNode; + } + + if (parent) { + language = (parent.className.match(lang) || [,''])[1]; + grammar = Prism.languages[language]; + } + + if (!grammar) { + return; + } + + // Set language on the element, if not present + element.className = element.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + + // Set language on the parent, for styling + parent = element.parentNode; + + if (/pre/i.test(parent.nodeName)) { + parent.className = parent.className.replace(lang, '').replace(/\s+/g, ' ') + ' language-' + language; + } + + var code = element.textContent; + + if (!code) { + return; + } + + code = code.replace(/&/g, '&').replace(/</g, '<').replace(/\u00a0/g, ' '); + + var env = { + element: element, + language: language, + grammar: grammar, + code: code + }; + + Prism.hooks.run('before-highlight', env); + + env.highlightedCode = Prism.highlight(env.code, env.grammar, env.language); + + Prism.hooks.run('before-insert', env); + + env.element.innerHTML = env.highlightedCode; + + if (callback) { + callback.call(element); + } + + Prism.hooks.run('after-highlight', env); + }, + + highlight: function (text, grammar, language) { + return Token.stringify(Prism.tokenize(text, grammar), language); + }, + + tokenize: function(text, grammar, language) { + var Token = Prism.Token; + + var strarr = [text]; + + var rest = grammar.rest; + var token; + if (rest) { + for (token in rest) { + grammar[token] = rest[token]; + } + + delete grammar.rest; + } + + tokenloop: + for (token in grammar) { + if (!grammar.hasOwnProperty(token) || !grammar[token]) { + continue; + } + + var pattern = grammar[token], + inside = pattern.inside, + lookbehind = !!pattern.lookbehind, + lookbehindLength = 0; + + pattern = pattern.pattern || pattern; + + for (var i=0; i<strarr.length; i++) { // Don’t cache length as it changes during the loop + + var str = strarr[i]; + + if (strarr.length > text.length) { + // Something went terribly wrong, ABORT, ABORT! + break tokenloop; + } + + if (str instanceof Token) { + continue; + } + + pattern.lastIndex = 0; + + var match = pattern.exec(str); + + if (match) { + if (lookbehind) { + lookbehindLength = match[1].length; + } + + var from = match.index - 1 + lookbehindLength; + match = match[0].slice(lookbehindLength); + var len = match.length; + var to = from + len; + var before = str.slice(0, from + 1); + var after = str.slice(to + 1); + + var args = [i, 1]; + + if (before) { + args.push(before); + } + + var wrapped = new Token(token, inside? Prism.tokenize(match, inside) : match); + + args.push(wrapped); + + if (after) { + args.push(after); + } + + Array.prototype.splice.apply(strarr, args); + } + } + } + + return strarr; + }, + + hooks: { + all: {}, + + add: function (name, callback) { + var hooks = Prism.hooks.all; + + hooks[name] = hooks[name] || []; + + hooks[name].push(callback); + }, + + run: function (name, env) { + var callbacks = Prism.hooks.all[name]; + + if (!callbacks || !callbacks.length) { + return; + } + + callbacks.forEach(function(callback) { + callback(env); + }); + } + } +}; + +var Token = Prism.Token = function(type, content) { + this.type = type; + this.content = content; +}; + +Token.stringify = function(o, language, parent) { + if (typeof o == 'string') { + return o; + } + + if (Object.prototype.toString.call(o) == '[object Array]') { + return o.map(function(element) { + return Token.stringify(element, language, o); + }).join(''); + } + + var env = { + type: o.type, + content: Token.stringify(o.content, language, parent), + tag: 'span', + classes: ['token', o.type], + attributes: {}, + language: language, + parent: parent + }; + + if (env.type == 'comment') { + env.attributes.spellcheck = 'true'; + } + + Prism.hooks.run('wrap', env); + + var attributes = ''; + + for (var name in env.attributes) { + attributes += name + '="' + (env.attributes[name] || '') + '"'; + } + + return '<' + env.tag + ' class="' + env.classes.join(' ') + '" ' + attributes + '>' + env.content + '</' + env.tag + '>'; +}; + +Prism.languages.clike = { + 'comment': { + pattern: /(^|[^\\])(\/\*[\w\W]*?\*\/|(^|[^:])\/\/.*?(\r?\n|$))/g, + lookbehind: true + }, + 'string': /("|')(\\?.)*?\1/g, + 'class-name': { + pattern: /((?:(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/ig, + lookbehind: true, + inside: { + punctuation: /(\.|\\)/ + } + }, + 'keyword': /\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/g, + 'boolean': /\b(true|false)\b/g, + 'function': { + pattern: /[a-z0-9_]+\(/ig, + inside: { + punctuation: /\(/ + } + }, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g, + 'operator': /[-+]{1,2}|!|<=?|>=?|={1,3}|(&){1,2}|\|?\||\?|\*|\/|\~|\^|\%/g, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[{}[\];(),.:]/g +}; + +Prism.languages.javascript = Prism.languages.extend('clike', { + 'keyword': /\b(var|let|if|else|while|do|for|return|in|instanceof|function|new|with|typeof|try|throw|catch|finally|null|break|continue)\b/g, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?|NaN|-?Infinity)\b/g +}); + +Prism.languages.insertBefore('javascript', 'keyword', { + 'regex': { + pattern: /(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g, + lookbehind: true + } +}); + +if (Prism.languages.markup) { + Prism.languages.insertBefore('markup', 'tag', { + 'script': { + pattern: /(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig, + inside: Prism.languages.markup.tag.inside + }, + rest: Prism.languages.javascript + } + } + }); +} diff --git a/devtools/shared/gcli/source/lib/gcli/util/spell.js b/devtools/shared/gcli/source/lib/gcli/util/spell.js new file mode 100644 index 000000000..f16724f2a --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/spell.js @@ -0,0 +1,197 @@ +/* + * 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'; + +/* + * A spell-checker based on Damerau-Levenshtein distance. + */ + +var CASE_CHANGE_COST = 1; +var INSERTION_COST = 10; +var DELETION_COST = 10; +var SWAP_COST = 10; +var SUBSTITUTION_COST = 20; +var MAX_EDIT_DISTANCE = 40; + +/** + * Compute Damerau-Levenshtein Distance, with a modification to allow a low + * case-change cost (1/10th of a swap-cost) + * @see http://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance + */ +var distance = exports.distance = function(wordi, wordj) { + var wordiLen = wordi.length; + var wordjLen = wordj.length; + + // We only need to store three rows of our dynamic programming matrix. + // (Without swap, it would have been two.) + var row0 = new Array(wordiLen+1); + var row1 = new Array(wordiLen+1); + var row2 = new Array(wordiLen+1); + var tmp; + + var i, j; + + // The distance between the empty string and a string of size i is the cost + // of i insertions. + for (i = 0; i <= wordiLen; i++) { + row1[i] = i * INSERTION_COST; + } + + // Row-by-row, we're computing the edit distance between substrings wordi[0..i] + // and wordj[0..j]. + for (j = 1; j <= wordjLen; j++) { + // Edit distance between wordi[0..0] and wordj[0..j] is the cost of j + // insertions. + row0[0] = j * INSERTION_COST; + + for (i = 1; i <= wordiLen; i++) { + // Handle deletion, insertion and substitution: we can reach each cell + // from three other cells corresponding to those three operations. We + // want the minimum cost. + var dc = row0[i - 1] + DELETION_COST; + var ic = row1[i] + INSERTION_COST; + var sc0; + if (wordi[i-1] === wordj[j-1]) { + sc0 = 0; + } + else { + if (wordi[i-1].toLowerCase() === wordj[j-1].toLowerCase()) { + sc0 = CASE_CHANGE_COST; + } + else { + sc0 = SUBSTITUTION_COST; + } + } + var sc = row1[i-1] + sc0; + + row0[i] = Math.min(dc, ic, sc); + + // We handle swap too, eg. distance between help and hlep should be 1. If + // we find such a swap, there's a chance to update row0[1] to be lower. + if (i > 1 && j > 1 && wordi[i-1] === wordj[j-2] && wordj[j-1] === wordi[i-2]) { + row0[i] = Math.min(row0[i], row2[i-2] + SWAP_COST); + } + } + + tmp = row2; + row2 = row1; + row1 = row0; + row0 = tmp; + } + + return row1[wordiLen]; +}; + +/** + * As distance() except that we say that if word is a prefix of name then we + * only count the case changes. This allows us to use words that can be + * completed by typing as more likely than short words + */ +var distancePrefix = exports.distancePrefix = function(word, name) { + var dist = 0; + + for (var i = 0; i < word.length; i++) { + if (name[i] !== word[i]) { + if (name[i].toLowerCase() === word[i].toLowerCase()) { + dist++; + } + else { + // name does not start with word, even ignoring case, use + // Damerau-Levenshtein + return exports.distance(word, name); + } + } + } + + return dist; +}; + +/** + * A function that returns the correction for the specified word. + */ +exports.correct = function(word, names) { + if (names.length === 0) { + return undefined; + } + + var distances = {}; + var sortedCandidates; + + names.forEach(function(candidate) { + distances[candidate] = exports.distance(word, candidate); + }); + + sortedCandidates = names.sort(function(worda, wordb) { + if (distances[worda] !== distances[wordb]) { + return distances[worda] - distances[wordb]; + } + else { + // if the score is the same, always return the first string + // in the lexicographical order + return worda < wordb; + } + }); + + if (distances[sortedCandidates[0]] <= MAX_EDIT_DISTANCE) { + return sortedCandidates[0]; + } + else { + return undefined; + } +}; + +/** + * Return a ranked list of matches: + * + * spell.rank('fred', [ 'banana', 'fred', 'ed', 'red' ]); + * ↓ + * [ + * { name: 'fred', dist: 0 }, + * { name: 'red', dist: 1 }, + * { name: 'ed', dist: 2 }, + * { name: 'banana', dist: 10 }, + * ] + * + * @param word The string that we're comparing names against + * @param names An array of strings to compare word against + * @param options Comparison options: + * - noSort: Do not sort the output by distance + * - prefixZero: Count prefix matches as edit distance 0 (i.e. word='bana' and + * names=['banana'], would return { name:'banana': dist: 0 }) This is useful + * if someone is typing the matches and may not have finished yet + */ +exports.rank = function(word, names, options) { + options = options || {}; + + var reply = names.map(function(name) { + // If any name starts with the word then the distance is based on the + // number of case changes rather than Damerau-Levenshtein + var algo = options.prefixZero ? distancePrefix : distance; + return { + name: name, + dist: algo(word, name) + }; + }); + + if (!options.noSort) { + reply = reply.sort(function(d1, d2) { + return d1.dist - d2.dist; + }); + } + + return reply; +}; diff --git a/devtools/shared/gcli/source/lib/gcli/util/util.js b/devtools/shared/gcli/source/lib/gcli/util/util.js new file mode 100644 index 000000000..065bf36c0 --- /dev/null +++ b/devtools/shared/gcli/source/lib/gcli/util/util.js @@ -0,0 +1,685 @@ +/* + * 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'; + +/* + * A number of DOM manipulation and event handling utilities. + */ + +//------------------------------------------------------------------------------ + +var eventDebug = false; + +/** + * Patch up broken console API from node + */ +if (eventDebug) { + if (console.group == null) { + console.group = function() { console.log(arguments); }; + } + if (console.groupEnd == null) { + console.groupEnd = function() { console.log(arguments); }; + } +} + +/** + * Useful way to create a name for a handler, used in createEvent() + */ +function nameFunction(handler) { + var scope = handler.scope ? handler.scope.constructor.name + '.' : ''; + var name = handler.func.name; + if (name) { + return scope + name; + } + for (var prop in handler.scope) { + if (handler.scope[prop] === handler.func) { + return scope + prop; + } + } + return scope + handler.func; +} + +/** + * Create an event. + * For use as follows: + * + * function Hat() { + * this.putOn = createEvent('Hat.putOn'); + * ... + * } + * Hat.prototype.adorn = function(person) { + * this.putOn({ hat: hat, person: person }); + * ... + * } + * + * var hat = new Hat(); + * hat.putOn.add(function(ev) { + * console.log('The hat ', ev.hat, ' has is worn by ', ev.person); + * }, scope); + * + * @param name Optional name to help with debugging + */ +exports.createEvent = function(name) { + var handlers = []; + var fireHoldCount = 0; + var heldEvents = []; + var eventCombiner; + + /** + * This is how the event is triggered. + * @param ev The event object to be passed to the event listeners + */ + var event = function(ev) { + if (fireHoldCount > 0) { + heldEvents.push(ev); + if (eventDebug) { + console.log('Held fire: ' + name, ev); + } + return; + } + + if (eventDebug) { + console.group('Fire: ' + name + ' to ' + handlers.length + ' listeners', ev); + } + + // Use for rather than forEach because it step debugs better, which is + // important for debugging events + for (var i = 0; i < handlers.length; i++) { + var handler = handlers[i]; + if (eventDebug) { + console.log(nameFunction(handler)); + } + handler.func.call(handler.scope, ev); + } + + if (eventDebug) { + console.groupEnd(); + } + }; + + /** + * Add a new handler function + * @param func The function to call when this event is triggered + * @param scope Optional 'this' object for the function call + */ + event.add = function(func, scope) { + if (typeof func !== 'function') { + throw new Error(name + ' add(func,...), 1st param is ' + typeof func); + } + + if (eventDebug) { + console.log('Adding listener to ' + name); + } + + handlers.push({ func: func, scope: scope }); + }; + + /** + * Remove a handler function added through add(). Both func and scope must + * be strict equals (===) the values used in the call to add() + * @param func The function to call when this event is triggered + * @param scope Optional 'this' object for the function call + */ + event.remove = function(func, scope) { + if (eventDebug) { + console.log('Removing listener from ' + name); + } + + var found = false; + handlers = handlers.filter(function(test) { + var match = (test.func === func && test.scope === scope); + if (match) { + found = true; + } + return !match; + }); + if (!found) { + console.warn('Handler not found. Attached to ' + name); + } + }; + + /** + * Remove all handlers. + * Reset the state of this event back to it's post create state + */ + event.removeAll = function() { + handlers = []; + }; + + /** + * Fire an event just once using a promise. + */ + event.once = function() { + if (arguments.length !== 0) { + throw new Error('event.once uses promise return values'); + } + + return new Promise(function(resolve, reject) { + var handler = function(arg) { + event.remove(handler); + resolve(arg); + }; + + event.add(handler); + }); + }; + + /** + * Temporarily prevent this event from firing. + * @see resumeFire(ev) + */ + event.holdFire = function() { + if (eventDebug) { + console.group('Holding fire: ' + name); + } + + fireHoldCount++; + }; + + /** + * Resume firing events. + * If there are heldEvents, then we fire one event to cover them all. If an + * event combining function has been provided then we use that to combine the + * events. Otherwise the last held event is used. + * @see holdFire() + */ + event.resumeFire = function() { + if (eventDebug) { + console.groupEnd('Resume fire: ' + name); + } + + if (fireHoldCount === 0) { + throw new Error('fireHoldCount === 0 during resumeFire on ' + name); + } + + fireHoldCount--; + if (heldEvents.length === 0) { + return; + } + + if (heldEvents.length === 1) { + event(heldEvents[0]); + } + else { + var first = heldEvents[0]; + var last = heldEvents[heldEvents.length - 1]; + if (eventCombiner) { + event(eventCombiner(first, last, heldEvents)); + } + else { + event(last); + } + } + + heldEvents = []; + }; + + /** + * When resumeFire has a number of events to combine, by default it just + * picks the last, however you can provide an eventCombiner which returns a + * combined event. + * eventCombiners will be passed 3 parameters: + * - first The first event to be held + * - last The last event to be held + * - all An array containing all the held events + * The return value from an eventCombiner is expected to be an event object + */ + Object.defineProperty(event, 'eventCombiner', { + set: function(newEventCombiner) { + if (typeof newEventCombiner !== 'function') { + throw new Error('eventCombiner is not a function'); + } + eventCombiner = newEventCombiner; + }, + + enumerable: true + }); + + return event; +}; + +//------------------------------------------------------------------------------ + +/** + * promiseEach is roughly like Array.forEach except that the action is taken to + * be something that completes asynchronously, returning a promise, so we wait + * for the action to complete for each array element before moving onto the + * next. + * @param array An array of objects to enumerate + * @param action A function to call for each member of the array + * @param scope Optional object to use as 'this' for the function calls + * @return A promise which is resolved (with an array of resolution values) + * when all the array members have been passed to the action function, and + * rejected as soon as any of the action function calls fails + */ +exports.promiseEach = function(array, action, scope) { + if (array.length === 0) { + return Promise.resolve([]); + } + + var allReply = []; + var promise = Promise.resolve(); + + array.forEach(function(member, i) { + promise = promise.then(function() { + var reply = action.call(scope, member, i, array); + return Promise.resolve(reply).then(function(data) { + allReply[i] = data; + }); + }); + }); + + return promise.then(function() { + return allReply; + }); +}; + +/** + * Catching errors from promises isn't as simple as: + * promise.then(handler, console.error); + * for a number of reasons: + * - chrome's console doesn't have bound functions (why?) + * - we don't get stack traces out from console.error(ex); + */ +exports.errorHandler = function(ex) { + if (ex instanceof Error) { + // V8 weirdly includes the exception message in the stack + if (ex.stack.indexOf(ex.message) !== -1) { + console.error(ex.stack); + } + else { + console.error('' + ex); + console.error(ex.stack); + } + } + else { + console.error(ex); + } +}; + + +//------------------------------------------------------------------------------ + +/** + * Copy the properties from one object to another in a way that preserves + * function properties as functions rather than copying the calculated value + * as copy time + */ +exports.copyProperties = function(src, dest) { + for (var key in src) { + var descriptor; + var obj = src; + while (true) { + descriptor = Object.getOwnPropertyDescriptor(obj, key); + if (descriptor != null) { + break; + } + obj = Object.getPrototypeOf(obj); + if (obj == null) { + throw new Error('Can\'t find descriptor of ' + key); + } + } + + if ('value' in descriptor) { + dest[key] = src[key]; + } + else if ('get' in descriptor) { + Object.defineProperty(dest, key, { + get: descriptor.get, + set: descriptor.set, + enumerable: descriptor.enumerable + }); + } + else { + throw new Error('Don\'t know how to copy ' + key + ' property.'); + } + } +}; + +//------------------------------------------------------------------------------ + +/** + * XHTML namespace + */ +exports.NS_XHTML = 'http://www.w3.org/1999/xhtml'; + +/** + * XUL namespace + */ +exports.NS_XUL = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +/** + * Create an HTML or XHTML element depending on whether the document is HTML + * or XML based. Where HTML/XHTML elements are distinguished by whether they + * are created using doc.createElementNS('http://www.w3.org/1999/xhtml', tag) + * or doc.createElement(tag) + * If you want to create a XUL element then you don't have a problem knowing + * what namespace you want. + * @param doc The document in which to create the element + * @param tag The name of the tag to create + * @returns The created element + */ +exports.createElement = function(doc, tag) { + if (exports.isXmlDocument(doc)) { + return doc.createElementNS(exports.NS_XHTML, tag); + } + else { + return doc.createElement(tag); + } +}; + +/** + * Remove all the child nodes from this node + * @param elem The element that should have it's children removed + */ +exports.clearElement = function(elem) { + while (elem.hasChildNodes()) { + elem.removeChild(elem.firstChild); + } +}; + +var isAllWhitespace = /^\s*$/; + +/** + * Iterate over the children of a node looking for TextNodes that have only + * whitespace content and remove them. + * This utility is helpful when you have a template which contains whitespace + * so it looks nice, but where the whitespace interferes with the rendering of + * the page + * @param elem The element which should have blank whitespace trimmed + * @param deep Should this node removal include child elements + */ +exports.removeWhitespace = function(elem, deep) { + var i = 0; + while (i < elem.childNodes.length) { + var child = elem.childNodes.item(i); + if (child.nodeType === 3 /*Node.TEXT_NODE*/ && + isAllWhitespace.test(child.textContent)) { + elem.removeChild(child); + } + else { + if (deep && child.nodeType === 1 /*Node.ELEMENT_NODE*/) { + exports.removeWhitespace(child, deep); + } + i++; + } + } +}; + +/** + * Create a style element in the document head, and add the given CSS text to + * it. + * @param cssText The CSS declarations to append + * @param doc The document element to work from + * @param id Optional id to assign to the created style tag. If the id already + * exists on the document, we do not add the CSS again. + */ +exports.importCss = function(cssText, doc, id) { + if (!cssText) { + return undefined; + } + + doc = doc || document; + + if (!id) { + id = 'hash-' + hash(cssText); + } + + var found = doc.getElementById(id); + if (found) { + if (found.tagName.toLowerCase() !== 'style') { + console.error('Warning: importCss passed id=' + id + + ', but that pre-exists (and isn\'t a style tag)'); + } + return found; + } + + var style = exports.createElement(doc, 'style'); + style.id = id; + style.appendChild(doc.createTextNode(cssText)); + + var head = doc.getElementsByTagName('head')[0] || doc.documentElement; + head.appendChild(style); + + return style; +}; + +/** + * Simple hash function which happens to match Java's |String.hashCode()| + * Done like this because I we don't need crypto-security, but do need speed, + * and I don't want to spend a long time working on it. + * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + */ +function hash(str) { + var h = 0; + if (str.length === 0) { + return h; + } + for (var i = 0; i < str.length; i++) { + var character = str.charCodeAt(i); + h = ((h << 5) - h) + character; + h = h & h; // Convert to 32bit integer + } + return h; +} + +/** + * Shortcut for clearElement/createTextNode/appendChild to make up for the lack + * of standards around textContent/innerText + */ +exports.setTextContent = function(elem, text) { + exports.clearElement(elem); + var child = elem.ownerDocument.createTextNode(text); + elem.appendChild(child); +}; + +/** + * There are problems with innerHTML on XML documents, so we need to do a dance + * using document.createRange().createContextualFragment() when in XML mode + */ +exports.setContents = function(elem, contents) { + if (typeof HTMLElement !== 'undefined' && contents instanceof HTMLElement) { + exports.clearElement(elem); + elem.appendChild(contents); + return; + } + + if ('innerHTML' in elem) { + elem.innerHTML = contents; + } + else { + try { + var ns = elem.ownerDocument.documentElement.namespaceURI; + if (!ns) { + ns = exports.NS_XHTML; + } + exports.clearElement(elem); + contents = '<div xmlns="' + ns + '">' + contents + '</div>'; + var range = elem.ownerDocument.createRange(); + var child = range.createContextualFragment(contents).firstChild; + while (child.hasChildNodes()) { + elem.appendChild(child.firstChild); + } + } + catch (ex) { + console.error('Bad XHTML', ex); + console.trace(); + throw ex; + } + } +}; + +/** + * How to detect if we're in an XML document. + * In a Mozilla we check that document.xmlVersion = null, however in Chrome + * we use document.contentType = undefined. + * @param doc The document element to work from (defaulted to the global + * 'document' if missing + */ +exports.isXmlDocument = function(doc) { + doc = doc || document; + // Best test for Firefox + if (doc.contentType && doc.contentType != 'text/html') { + return true; + } + // Best test for Chrome + if (doc.xmlVersion != null) { + return true; + } + return false; +}; + +/** + * We'd really like to be able to do 'new NodeList()' + */ +exports.createEmptyNodeList = function(doc) { + if (doc.createDocumentFragment) { + return doc.createDocumentFragment().childNodes; + } + return doc.querySelectorAll('x>:root'); +}; + +//------------------------------------------------------------------------------ + +/** + * Keyboard handling is a mess. http://unixpapa.com/js/key.html + * It would be good to use DOM L3 Keyboard events, + * http://www.w3.org/TR/2010/WD-DOM-Level-3-Events-20100907/#events-keyboardevents + * however only Webkit supports them, and there isn't a shim on Modernizr: + * https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-browser-Polyfills + * and when the code that uses this KeyEvent was written, nothing was clear, + * so instead, we're using this unmodern shim: + * http://stackoverflow.com/questions/5681146/chrome-10-keyevent-or-something-similar-to-firefoxs-keyevent + * See BUG 664991: GCLI's keyboard handling should be updated to use DOM-L3 + * https://bugzilla.mozilla.org/show_bug.cgi?id=664991 + */ +exports.KeyEvent = { + DOM_VK_CANCEL: 3, + DOM_VK_HELP: 6, + DOM_VK_BACK_SPACE: 8, + DOM_VK_TAB: 9, + DOM_VK_CLEAR: 12, + DOM_VK_RETURN: 13, + DOM_VK_SHIFT: 16, + DOM_VK_CONTROL: 17, + DOM_VK_ALT: 18, + DOM_VK_PAUSE: 19, + DOM_VK_CAPS_LOCK: 20, + DOM_VK_ESCAPE: 27, + DOM_VK_SPACE: 32, + DOM_VK_PAGE_UP: 33, + DOM_VK_PAGE_DOWN: 34, + DOM_VK_END: 35, + DOM_VK_HOME: 36, + DOM_VK_LEFT: 37, + DOM_VK_UP: 38, + DOM_VK_RIGHT: 39, + DOM_VK_DOWN: 40, + DOM_VK_PRINTSCREEN: 44, + DOM_VK_INSERT: 45, + DOM_VK_DELETE: 46, + DOM_VK_0: 48, + DOM_VK_1: 49, + DOM_VK_2: 50, + DOM_VK_3: 51, + DOM_VK_4: 52, + DOM_VK_5: 53, + DOM_VK_6: 54, + DOM_VK_7: 55, + DOM_VK_8: 56, + DOM_VK_9: 57, + DOM_VK_SEMICOLON: 59, + DOM_VK_EQUALS: 61, + DOM_VK_A: 65, + DOM_VK_B: 66, + DOM_VK_C: 67, + DOM_VK_D: 68, + DOM_VK_E: 69, + DOM_VK_F: 70, + DOM_VK_G: 71, + DOM_VK_H: 72, + DOM_VK_I: 73, + DOM_VK_J: 74, + DOM_VK_K: 75, + DOM_VK_L: 76, + DOM_VK_M: 77, + DOM_VK_N: 78, + DOM_VK_O: 79, + DOM_VK_P: 80, + DOM_VK_Q: 81, + DOM_VK_R: 82, + DOM_VK_S: 83, + DOM_VK_T: 84, + DOM_VK_U: 85, + DOM_VK_V: 86, + DOM_VK_W: 87, + DOM_VK_X: 88, + DOM_VK_Y: 89, + DOM_VK_Z: 90, + DOM_VK_CONTEXT_MENU: 93, + DOM_VK_NUMPAD0: 96, + DOM_VK_NUMPAD1: 97, + DOM_VK_NUMPAD2: 98, + DOM_VK_NUMPAD3: 99, + DOM_VK_NUMPAD4: 100, + DOM_VK_NUMPAD5: 101, + DOM_VK_NUMPAD6: 102, + DOM_VK_NUMPAD7: 103, + DOM_VK_NUMPAD8: 104, + DOM_VK_NUMPAD9: 105, + DOM_VK_MULTIPLY: 106, + DOM_VK_ADD: 107, + DOM_VK_SEPARATOR: 108, + DOM_VK_SUBTRACT: 109, + DOM_VK_DECIMAL: 110, + DOM_VK_DIVIDE: 111, + DOM_VK_F1: 112, + DOM_VK_F2: 113, + DOM_VK_F3: 114, + DOM_VK_F4: 115, + DOM_VK_F5: 116, + DOM_VK_F6: 117, + DOM_VK_F7: 118, + DOM_VK_F8: 119, + DOM_VK_F9: 120, + DOM_VK_F10: 121, + DOM_VK_F11: 122, + DOM_VK_F12: 123, + DOM_VK_F13: 124, + DOM_VK_F14: 125, + DOM_VK_F15: 126, + DOM_VK_F16: 127, + DOM_VK_F17: 128, + DOM_VK_F18: 129, + DOM_VK_F19: 130, + DOM_VK_F20: 131, + DOM_VK_F21: 132, + DOM_VK_F22: 133, + DOM_VK_F23: 134, + DOM_VK_F24: 135, + DOM_VK_NUM_LOCK: 144, + DOM_VK_SCROLL_LOCK: 145, + DOM_VK_COMMA: 188, + DOM_VK_PERIOD: 190, + DOM_VK_SLASH: 191, + DOM_VK_BACK_QUOTE: 192, + DOM_VK_OPEN_BRACKET: 219, + DOM_VK_BACK_SLASH: 220, + DOM_VK_CLOSE_BRACKET: 221, + DOM_VK_QUOTE: 222, + DOM_VK_META: 224 +}; |