// Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
// Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
// Copyright (c) 2008-2015 Kris Maglione <maglione.k at Gmail>
//
// This work is licensed for reuse under an MIT license. Details are
// given in the LICENSE.txt file included with this file.
"use strict";

try {

defineModule("commands", {
    exports: ["ArgType", "Command", "Commands", "CommandOption", "Ex", "commands"],
    require: ["contexts", "messages", "util"]
});

lazyRequire("help", ["help"]);
lazyRequire("options", ["Option"]);
lazyRequire("template", ["template"]);

/**
 * A structure representing the options available for a command.
 *
 * Do NOT create instances of this class yourself, use the helper method
 * {@see Commands#add} instead
 *
 * @property {[string]} names An array of option names. The first name
 *     is the canonical option name.
 * @property {number} type The option's value type. This is one of:
 *         (@link CommandOption.NOARG),
 *         (@link CommandOption.STRING),
 *         (@link CommandOption.STRINGMAP),
 *         (@link CommandOption.BOOL),
 *         (@link CommandOption.INT),
 *         (@link CommandOption.FLOAT),
 *         (@link CommandOption.LIST),
 *         (@link CommandOption.ANY)
 * @property {function} validator A validator function
 * @property {function (CompletionContext, object)} completer A list of
 *    completions, or a completion function which will be passed a
 *    {@link CompletionContext} and an object like that returned by
 *    {@link commands.parseArgs} with the following additional keys:
 *      completeOpt - The name of the option currently being completed.
 * @property {boolean} multiple Whether this option can be specified multiple times
 * @property {string} description A description of the option
 * @property {object} default The option's default value
 */

var CommandOption = Struct("names", "type", "validator", "completer", "multiple", "description", "default");
CommandOption.defaultValue("description", () => "");
CommandOption.defaultValue("type", () => CommandOption.NOARG);
CommandOption.defaultValue("multiple", () => false);

var ArgType = Struct("description", "parse");
update(CommandOption, {
    /**
     * @property {object} The option argument is unspecified. Any argument
     *     is accepted and caller is responsible for parsing the return
     *     value.
     * @final
     */
    ANY: 0,

    /**
     * @property {object} The option doesn't accept an argument.
     * @final
     */
    NOARG: ArgType("no arg",  arg => !arg || null),
    /**
     * @property {object} The option accepts a boolean argument.
     * @final
     */
    BOOL: ArgType("boolean", function parseBoolArg(val) {
        return Commands.parseBool(val);
    }),
    /**
     * @property {object} The option accepts a string argument.
     * @final
     */
    STRING: ArgType("string", val => val),
    /**
     * @property {object} The option accepts a stringmap argument.
     * @final
     */
    STRINGMAP: ArgType("stringmap", (val, quoted) => Option.parse.stringmap(quoted)),
    /**
     * @property {object} The option accepts an integer argument.
     * @final
     */
    INT: ArgType("int", function parseIntArg(val) {
        return parseInt(val);
    }),
    /**
     * @property {object} The option accepts a float argument.
     * @final
     */
    FLOAT: ArgType("float", function parseFloatArg(val) {
        return parseFloat(val);
    }),
    /**
     * @property {object} The option accepts a string list argument.
     *     E.g. "foo,bar"
     * @final
     */
    LIST: ArgType("list", function parseListArg(arg, quoted) {
        return Option.splitList(quoted);
    })
});

/**
 * A class representing Ex commands. Instances are created by
 * the {@link Commands} class.
 *
 * @param {[string]} specs The names by which this command can be invoked.
 *     These are specified in the form "com[mand]" where "com" is a unique
 *     command name prefix.
 * @param {string} description A short one line description of the command.
 * @param {function} action The action invoked by this command when executed.
 * @param {Object} extra An optional extra configuration hash. The following
 * properties are supported.
 *         always      - see {@link Command#always}
 *         argCount    - see {@link Command#argCount}
 *         bang        - see {@link Command#bang}
 *         completer   - see {@link Command#completer}
 *         count       - see {@link Command#count}
 *         domains     - see {@link Command#domains}
 *         heredoc     - see {@link Command#heredoc}
 *         literal     - see {@link Command#literal}
 *         options     - see {@link Command#options}
 *         privateData - see {@link Command#privateData}
 *         serialize   - see {@link Command#serialize}
 *         subCommand  - see {@link Command#subCommand}
 * @optional
 * @private
 */
var Command = Class("Command", {
    init: function init(specs, description, action, extra) {
        specs = Array.concat(specs); // XXX

        this.specs = specs;
        this.description = description;
        this.action = action;

        if (extra.options)
            this._options = extra.options;
        delete extra.options;

        if (extra)
            this.update(extra);
    },

    get toStringParams() { return [this.name, this.hive.name]; },

    get identifier() { return this.hive.prefix + this.name; },

    get helpTag() { return ":" + this.name; },

    get lastCommand() {
        return this._lastCommand || this.modules.commandline.command;
    },
    set lastCommand(val) { this._lastCommand = val; },

    /**
     * Execute this command.
     *
     * @param {Args} args The Args object passed to {@link #action}.
     * @param {Object} modifiers Any modifiers to be passed to {@link #action}.
     */
    execute: function execute(args, modifiers={}) {
        const { dactyl } = this.modules;

        let context = args.context;
        if (this.deprecated)
            this.warn(context, "deprecated", _("warn.deprecated", ":" + this.name, this.deprecated));

        if (args.count != null && !this.count)
            throw FailedAssertion(_("command.noCount"));
        if (args.bang && !this.bang)
            throw FailedAssertion(_("command.noBang"));

        args.doc = this.hive.group.lastDocument;

        return !dactyl.trapErrors(function exec() {
            let extra = this.hive.argsExtra(args);

            for (let k of properties(extra))
                if (!(k in args))
                    Object.defineProperty(args, k, Object.getOwnPropertyDescriptor(extra, k));

            if (this.always)
                this.always(args, modifiers);

            if (!context || !context.noExecute)
                this.action(args, modifiers);
        }, this);
    },

    /**
     * Returns whether this command may be invoked via *name*.
     *
     * @param {string} name The candidate name.
     * @returns {boolean}
     */
    hasName: function hasName(name) {
        return Command.hasName(this.parsedSpecs, name);
    },

    /**
     * A helper function to parse an argument string.
     *
     * @param {string} args The argument string to parse.
     * @param {CompletionContext} complete A completion context.
     *     Non-null when the arguments are being parsed for completion
     *     purposes.
     * @param {Object} extra Extra keys to be spliced into the
     *     returned Args object.
     * @returns {Args}
     * @see Commands#parseArgs
     */
    parseArgs: function parseArgs(args, complete, extra) {
        return this.modules.commands.parseArgs(args, {
            __proto__: this,
            complete: complete,
            extra: extra
        });
    },

    complained: Class.Memoize(() => new RealSet),

    /**
     * @property {[string]} All of this command's name specs. e.g., "com[mand]"
     */
    specs: null,
    parsedSpecs: Class.Memoize(function () {
        return Command.parseSpecs(this.specs);
    }),

    /** @property {[string]} All of this command's short names, e.g., "com" */
    shortNames: Class.Memoize(function () {
        return Ary.compact(this.parsedSpecs.map(n => n[1]));
    }),

    /**
     * @property {[string]} All of this command's long names, e.g., "command"
     */
    longNames: Class.Memoize(function () {
        return this.parsedSpecs.map(n => n[0]);
    }),

    /** @property {string} The command's canonical name. */
    name: Class.Memoize(function () { return this.longNames[0]; }),

    /** @property {[string]} All of this command's long and short names. */
    names: Class.Memoize(function () {
        return this.parsedSpecs.flatMap();
    }),

    /** @property {string} This command's description, as shown in :listcommands */
    description: Messages.Localized(""),

    /** @property {string|null} If set, the deprecation message for this command. */
    deprecated: Messages.Localized(null),

    /**
     * @property {function (Args)} The function called to execute this command.
     */
    action: null,

    /**
     * @property {function (Args)} A function which is called when this
     * command is encountered, even if we are ignoring commands. Used to
     * implement control structures.
     */
    always: null,

    /**
     * @property {string} This command's argument count spec.
     * @see Commands#parseArguments
     */
    argCount: 0,

    /**
     * @property {function (CompletionContext, Args)} This command's completer.
     * @see CompletionContext
     */
    completer: null,

    /** @property {boolean} Whether this command accepts a here document. */
    hereDoc: false,

    /**
     * @property {boolean} Whether this command may be called with a bang,
     *     e.g., :com!
     */
    bang: false,

    /**
     * @property {boolean} Whether this command may be called with a count,
     *     e.g., :12bdel
     */
    count: false,

    /**
     * @property {function(args)} A function which should return a list
     *     of domains referenced in the given args. Used in determining
     *     whether to purge the command from history when clearing
     *     private data.
     */
    domains: function (args) { return []; },

    /**
     * @property {boolean} At what index this command's literal arguments
     *     begin. For instance, with a value of 2, all arguments starting with
     *     the third are parsed as a single string, with all quoting characters
     *     passed literally. This is especially useful for commands which take
     *     key mappings or Ex command lines as arguments.
     */
    literal: null,

    /**
     * @property {Array} The options this command takes.
     * @see Commands@parseArguments
     */
    options: Class.Memoize(function () {
        return this._options.map(function (opt) {
            let option = CommandOption.fromArray(opt);
            option.localeName = ["command", this.name, option.names[0]];
            return option;
        }, this);
    }),
    _options: [],

    optionMap: Class.Memoize(function () {
        return Ary.toObject(
            Array.from(this.options)
                 .flatMap(opt => opt.names.map(name => [name, opt])));
    }),

    newArgs: function newArgs(base) {
        let res = [];
        update(res, base);
        res.__proto__ = this.argsPrototype;
        return res;
    },

    argsPrototype: Class.Memoize(function argsPrototype() {
        let res = update([], {
                command: this,

                explicitOpts: Class.Memoize(() => ({})),

                has: function AP_has(opt) {
                    return hasOwnProp(this.explicitOpts, opt) ||
                           typeof opt === "number" && hasOwnProp(this, opt);
                },

                get literalArg() {
                    let { literal } = this.command;
                    return literal != null && this[literal] || "";
                },

                // TODO: string: Class.Memoize(function () { ... }),

                verify: function verify() {
                    if (this.command.argCount) {
                        util.assert((this.length > 0 || !/^[1+]$/.test(this.command.argCount)) &&
                                    (this.literal == null || !/[1+]/.test(this.command.argCount) || /\S/.test(this.literalArg || "")),
                                     _("error.argumentRequired"));

                        util.assert((this.length == 0 || this.command.argCount !== "0") &&
                                    (this.length <= 1 || !/^[01?]$/.test(this.command.argCount)),
                                    _("error.trailingCharacters"));
                    }
                }
        });

        this.options.forEach(function (opt) {
            if (opt.default !== undefined) {
                let prop = Object.getOwnPropertyDescriptor(opt, "default") ||
                           {
                               configurable: true,
                               enumerable: true,
                               get: function () { return opt.default; }
                           };

                if (prop.get && !prop.set)
                    prop.set = function (val) { Class.replaceProperty(this, opt.names[0], val); };
                Object.defineProperty(res, opt.names[0], prop);
            }
        });

        return res;
    }),

    /**
     * @property {boolean|function(args)} When true, invocations of this
     *     command may contain private data which should be purged from
     *     saved histories when clearing private data. If a function, it
     *     should return true if an invocation with the given args
     *     contains private data
     */
    privateData: true,
    /**
     * @property {function} Should return an array of *Object*s suitable to be
     *     passed to {@link Commands#commandToString}, one for each past
     *     invocation which should be restored on subsequent @dactyl startups.
     */
    serialize: null,
    serialGroup: 50,
    /**
     * @property {number} If this command takes another ex command as an
     *     argument, the index of that argument. Used in determining whether to
     *     purge the command from history when clearing private data.
     */
    subCommand: null,
    /**
     * @property {boolean} Specifies whether this is a user command. User
     *     commands may be created by plugins, or directly by users, and,
     *     unlike basic commands, may be overwritten. Users and plugin authors
     *     should create only user commands.
     */
    user: false,
    /**
     * @property {string} For commands defined via :command, contains the Ex
     *     command line to be executed upon invocation.
     */
    replacementText: null,

    /**
     * Warns of a misuse of this command once per warning type per file.
     *
     * @param {object} context The calling context.
     * @param {string} type The type of warning.
     * @param {string} warning The warning message.
     */
    warn: function warn(context, type, message) {
        let loc = !context ? "" : [context.file, context.line, " "].join(":");

        let key = type + ":" + (context ? context.file : "[Command Line]");

        if (!this.complained.add(key))
            this.modules.dactyl.warn(loc + message);
    }
}, {
    hasName: function hasName(specs, name) {
        return specs.some(([long, short]) => name.indexOf(short) == 0 &&
                                             long.indexOf(name) == 0);
    },

    // TODO: do we really need more than longNames as a convenience anyway?
    /**
     *  Converts command name abbreviation specs of the form
     *  'shortname[optional-tail]' to short and long versions:
     *      ["abc[def]", "ghijkl"] ->  [["abcdef", "abc"], ["ghijlk"]]
     *
     *  @param {Array} specs An array of command name specs to parse.
     *  @returns {Array}
     */
    parseSpecs: function parseSpecs(specs) {
        return specs.map(spec => {
            let [, head, tail] = /([^[]+)(?:\[(.*)])?/.exec(spec);
            return tail ? [head + tail, head] : [head];
        });
    }
});

// Prototype.
var Ex = Module("Ex", {
    Local(dactyl, modules, window) {
        return {
            init() {},

            get commands() { return modules.commands; },
            get context() { return modules.contexts.context; }
        };
    },

    commands: null,

    init() {
        let proxy = new Proxy(this, {
            get(target, prop, receiver) {
                if (prop === "isProxy")
                    return [true, receiver === proxy].join(",");

                if (prop in target || receiver === proxy)
                    return target[prop];

                return function (...args) {
                    return this._run(prop)(...args);
                };
            },
        });

        return proxy;
    },

    _args(cmd, args) {
        args = Array.slice(args);

        let res = cmd.newArgs({ context: this.context });

        if (isObject(args[0]))
            for (let [k, v] of Object.entries(args.shift()))
                if (k == "!")
                    res.bang = v;
                else if (k == "#")
                    res.count = v;
                else {
                    let opt = cmd.optionMap["-" + k];
                    let val = opt.type && opt.type.parse(v);

                    util.assert(val != null && (typeof val !== "number" || !isNaN(val)),
                                _("option.noSuch", k));

                    Class.replaceProperty(res, opt.names[0], val);
                    res.explicitOpts[opt.names[0]] = val;
                }

        for (let [i, val] of args.entries())
            res[i] = String(val);

        return res;
    },

    _complete(cmd) {
        return (context, func, obj, args) => {
            args = this._args(cmd, args);
            args.completeArg = args.length - 1;
            if (cmd.completer && args.length)
                return cmd.completer(context, args);
        };
    },

    _run(name) {
        const self = this;
        let cmd = this.commands.get(name);
        util.assert(cmd, _("command.noSuch"));

        return update(function exCommand(options) {
            let args = self._args(cmd, arguments);
            args.verify();
            return cmd.execute(args);
        }, {
            dactylCompleter: self._complete(cmd)
        });
    },
});

var CommandHive = Class("CommandHive", Contexts.Hive, {
    init: function init(group) {
        init.supercall(this, group);

        this._map = {};
        this._list = [];
        this._specs = [];
    },

    /**
     * Caches this command hive.
     */

    cache: function cache() {
        let { cache } = this.modules;
        this.cached = true;

        let cached = cache.get(this.cacheKey, () => {
            this.cached = false;
            this.modules.moduleManager.initDependencies("commands");

            let map = {};
            for (let [name, cmd] of iter(this._map))
                if (cmd.sourceModule)
                    map[name] = { sourceModule: cmd.sourceModule, isPlaceholder: true };

            let specs = [];
            for (let cmd of this._list)
                for (let spec of cmd.parsedSpecs)
                    specs.push(spec.concat(cmd.name));

            return { map: map, specs: specs };
        });

        cached = cache.get(this.cacheKey);
        if (this.cached) {
            this._specs = cached.specs;
            for (let [k, v] of iter(cached.map))
                this._map[k] = v;
        }
    },

    get cacheKey() { return "commands/hives/" + this.name + ".json"; },

    /** @property {Iterator(Command)} @private */
    "@@iterator": function __iterator__() {
        if (this.cached)
            this.modules.initDependencies("commands");
        this.cached = false;

        return this._list.sort((a, b) => String.localeCompare(a.name, b.name))
                   .values();
    },

    /** @property {string} The last executed Ex command line. */
    repeat: null,

    /**
     * Adds a new command to the builtin hive. Accessible only to core dactyl
     * code. Plugins should use group.commands.add instead.
     *
     * @param {[string]} specs The names by which this command can be invoked.
     *     The first name specified is the command's canonical name.
     * @param {string} description A description of the command.
     * @param {function} action The action invoked by this command.
     * @param {Object} extra An optional extra configuration hash.
     *     @optional
     * @param {boolean} replace Replace an existing command of the same name.
     *     @optional
     */
    add: function add(specs, description, action, extra={}, replace=false) {
        const { commands, contexts } = this.modules;

        if (!extra.definedAt)
            extra.definedAt = contexts.getCaller(Components.stack.caller);
        if (!extra.sourceModule)
            extra.sourceModule = commands.currentDependency;

        extra.hive = this;
        extra.parsedSpecs = Command.parseSpecs(specs);

        let names = extra.parsedSpecs.flatMap();
        let name = names[0];

        if (this.name != "builtin") {
            util.assert(!names.some(name => name in commands.builtin._map),
                        _("command.cantReplace", name));

            util.assert(replace || names.every(name => !(name in this._map)),
                        _("command.wontReplace", name));
        }

        for (let name of names) {
            if (false)
                // For some reason, the `this` object of the getter gets
                // mutilated if we do this in recent Firefox versions.
                // Just rely on the proxy for now.
                Object.defineProperty(ex, name, {
                    configurable: true,
                    enumerable: true,
                    get() {
                        return this._run(name);
                    },
                });

            if (name in this._map && !this._map[name].isPlaceholder)
                this.remove(name);
        }

        let closure = () => this._map[name];

        memoize(this._map, name, () => commands.Command(specs, description, action, extra));
        if (!extra.hidden)
            memoize(this._list, this._list.length, closure);
        for (let alias of names.slice(1))
            memoize(this._map, alias, closure);

        return name;
    },

    _add: function _add(names, description, action, extra={}, replace=false) {
        const { contexts } = this.modules;
        extra.definedAt = contexts.getCaller(Components.stack.caller.caller);
        return apply(this, "add", arguments);
    },

    /**
     * Clear all commands.
     * @returns {Command}
     */
    clear: function clear() {
        util.assert(this.group.modifiable, _("command.cantDelete"));
        this._map = {};
        this._list = [];
    },

    /**
     * Returns the command with matching *name*.
     *
     * @param {string} name The name of the command to return. This can be
     *     any of the command's names.
     * @param {boolean} full If true, only return a command if one of
     *     its names matches *name* exactly.
     * @returns {Command}
     */
    get: function get(name, full) {
        let cmd = this._map[name]
               || !full && this._list.find(cmd => cmd.hasName(name))
               || null;

        if (!cmd && full) {
            // Hrm. This is wrong. -Kris
            let name = this._specs.find(spec => Command.hasName(spec, name));
            return name && this.get(name);
        }

        if (cmd && cmd.isPlaceholder) {
            this.modules.moduleManager.initDependencies("commands", [cmd.sourceModule]);
            cmd = this._map[name];
        }
        return cmd;
    },

    /**
     * Remove the user-defined command with matching *name*.
     *
     * @param {string} name The name of the command to remove. This can be
     *     any of the command's names.
     */
    remove: function remove(name) {
        util.assert(this.group.modifiable, _("command.cantDelete"));

        let cmd = this.get(name);
        this._list = this._list.filter(c => c !== cmd);
        for (let name of cmd.names)
            delete this._map[name];
    }
});

/**
 * @instance commands
 */
var Commands = Module("commands", {
    lazyInit: true,
    lazyDepends: true,

    Local: function Local(dactyl, modules, window) {
        let { contexts } = modules;
        return {
            init: function init() {
                this.Command = Class("Command", Command, { modules: modules });
                update(this, {
                    hives: contexts.Hives("commands", Class("CommandHive", CommandHive, { modules: modules })),
                    user: contexts.hives.commands.user,
                    builtin: contexts.hives.commands.builtin
                });
            },

            reallyInit: function reallyInit() {
                if (false)
                    this.builtin.cache();
                else
                    this.modules.moduleManager.initDependencies("commands");
            },

            get context() { return contexts.context; },

            get readHeredoc() { return modules.io.readHeredoc; },

            get allHives() { return contexts.allGroups.commands; },

            get userHives() {
                return this.allHives.filter(h => h !== this.builtin);
            },

            /**
             * Executes an Ex command script.
             *
             * @param {string} string A string containing the commands to execute.
             * @param {object} tokens An optional object containing tokens to be
             *      interpolated into the command string.
             * @param {object} args Optional arguments object to be passed to
             *      command actions.
             * @param {object} context An object containing information about
             *      the file that is being or has been sourced to obtain the
             *      command string.
             */
            execute: function execute(string, tokens, silent, args, context) {
                contexts.withContext(context || this.context || { file: "[Command Line]", line: 1 },
                                     function (context) {
                    modules.io.withSavedValues(["readHeredoc"], function () {
                        this.readHeredoc = function readHeredoc(end) {
                            let res = [];
                            contexts.context.line++;
                            while (++i < lines.length) {
                                if (lines[i] === end)
                                    return res.join("\n");
                                res.push(lines[i]);
                            }
                            util.assert(false, _("command.eof", end));
                        };

                        args = update({}, args || {});

                        if (tokens && !callable(string))
                            string = util.compileMacro(string, true);
                        if (callable(string))
                            string = string(tokens || {});

                        let lines = string.split(/\r\n|[\r\n]/);
                        let startLine = context.line;

                        for (var i = 0; i < lines.length && !context.finished; i++) {
                            // Deal with editors from Silly OSs.
                            let line = lines[i].replace(/\r$/, "");

                            context.line = startLine + i;

                            // Process escaped new lines
                            while (i < lines.length && /^\s*\\/.test(lines[i + 1]))
                                line += "\n" + lines[++i].replace(/^\s*\\/, "");

                            try {
                                dactyl.execute(line, args);
                            }
                            catch (e) {
                                if (!silent) {
                                    e.message = context.file + ":" + context.line + ": " + e.message;
                                    dactyl.reportError(e, true);
                                }
                            }
                        }
                    });
                });
            },

            /**
             * Lists all user-defined commands matching *filter* and optionally
             * *hives*.
             *
             * @param {string} filter Limits the list to those commands with a name
             *     matching this anchored substring.
             * @param {[Hive]} hives List of hives.
             * @optional
             */
            list: function list(filter, hives) {
                const { commandline, completion } = this.modules;
                function completerToString(completer) {
                    if (completer) {
                        for (let [key, val] of Object.entries(config.completers))
                             if (completion.bound(val) === completer)
                                 return key;

                         return "custom";
                    }

                    return "";
                }
                // TODO: allow matching of aliases?
                function cmds(hive) {
                    return hive._list.filter(cmd => cmd.name.startsWith(filter || ""));
                }

                hives = (hives || this.userHives).map(h => [h, cmds(h)])
                                                 .filter(([h, c]) => c.length);

                let list = ["table", {},
                    ["tr", { highlight: "Title" },
                        ["td"],
                        ["td", { style: "padding-right: 1em;" }],
                        ["td", { style: "padding-right: 1ex;" }, _("title.Name")],
                        ["td", { style: "padding-right: 1ex;" }, _("title.Args")],
                        ["td", { style: "padding-right: 1ex;" }, _("title.Range")],
                        ["td", { style: "padding-right: 1ex;" }, _("title.Complete")],
                        ["td", { style: "padding-right: 1ex;" }, _("title.Definition")]],
                    ["col", { style: "min-width: 6em; padding-right: 1em;" }],
                    hives.map(([hive, cmds]) => {
                        let i = 0;
                        return [
                            ["tr", { style: "height: .5ex;" }],
                            cmds.map(cmd =>
                                ["tr", {},
                                    ["td", { highlight: "Title" }, !i++ ? hive.name : ""],
                                    ["td", {}, cmd.bang ? "!" : " "],
                                    ["td", {}, cmd.name],
                                    ["td", {}, cmd.argCount],
                                    ["td", {}, cmd.count ? "0c" : ""],
                                    ["td", {}, completerToString(cmd.completer)],
                                    ["td", {}, cmd.replacementText || "function () { ... }"]]),
                            ["tr", { style: "height: .5ex;" }]];
                    })];

                // E4X-FIXME
                // if (list.*.length() === list.text().length() + 2)
                //     dactyl.echomsg(_("command.none"));
                // else
                commandline.commandOutput(list);
            }
        };
    },

    /**
     * @property Indicates that no count was specified for this
     *     command invocation.
     * @final
     */
    COUNT_NONE: null,
    /**
     * @property {number} Indicates that the full buffer range (1,$) was
     *     specified for this command invocation.
     * @final
     */
    // FIXME: this isn't a count at all
    COUNT_ALL: -2, // :%...

    /** @property {Iterator(Command)} @private */
    iterator: function iterator() {
        return iter.apply(null, this.hives.array)
                   .sort((a, b) => (a.serialGroup - b.serialGroup ||
                                    a.name > b.name))
                   .iterValues();
    },

    /** @property {string} The last executed Ex command line. */
    repeat: null,

    add: function add(names, description, action, extra={}, replace=false) {
        let { caller } = Components.stack;

        let group = this.builtin;
        if (!util.isDactyl(caller)) {
            deprecated.warn(add, "commands.add", "group.commands.add");
            group = this.user;
        }

        const { contexts } = this.modules;
        extra.definedAt = contexts.getCaller(caller);
        return apply(group, "add", arguments);
    },
    addUserCommand: deprecated("group.commands.add", { get: function addUserCommand() { return this.user.bound._add } }),
    getUserCommands: deprecated("iter(group.commands)", function getUserCommands() { return iter(this.user).toArray(); }),
    removeUserCommand: deprecated("group.commands.remove", { get: function removeUserCommand() { return this.user.bound.remove; } }),

    /**
     * Returns the specified command invocation object serialized to
     * an executable Ex command string.
     *
     * @param {Object} args The command invocation object.
     * @returns {string}
     */
    commandToString: function commandToString(args) {
        let res = [args.command + (args.bang ? "!" : "")];

        let defaults = {};
        if (args.ignoreDefaults)
            defaults = Ary.toObject(Array.from(this.options,
                                               opt => [opt.names[0], opt.default]));

        for (let [opt, val] of Object.entries(args.options || {})) {
            if (val === undefined)
                continue;
            if (val != null && defaults[opt] === val)
                continue;

            let chr = /^-.$/.test(opt) ? " " : "=";
            if (isArray(val))
                opt += chr + Option.stringify.stringlist(val);
            else if (val != null)
                opt += chr + Commands.quote(val);
            res.push(opt);
        }

        for (let arg of (args.arguments || []))
            res.push(Commands.quote(arg));

        let str = args.literalArg;
        if (str)
            res.push(!/\n/.test(str) ? str :
                     this.serializeHereDoc ? "<<EOF\n" + String.replace(str, /\n$/, "") + "\nEOF"
                                           : String.replace(str, /\n/g, "\n" + res[0].replace(/./g, " ").replace(/.$/, "\\")));
        return res.join(" ");
    },

    /**
     * Returns the command with matching *name*.
     *
     * @param {string} name The name of the command to return. This can be
     *     any of the command's names.
     * @returns {Command}
     */
    get: function get(name, full) {
        return iter(this.hives).map(([i, hive]) => hive.get(name, full))
                               .find(identity);
    },

    /**
     * Returns true if a command invocation contains a URL referring to the
     * domain *host*.
     *
     * @param {string} command
     * @param {string} host
     * @returns {boolean}
     */
    hasDomain: function hasDomain(command, host) {
        try {
            for (let [cmd, args] of this.subCommands(command))
                if (Array.concat(cmd.domains(args)).some(domain => util.isSubdomain(domain, host)))
                    return true;
        }
        catch (e) {
            util.reportError(e);
        }
        return false;
    },

    /**
     * Returns true if a command invocation contains private data which should
     * be cleared when purging private data.
     *
     * @param {string} command
     * @returns {boolean}
     */
    hasPrivateData: function hasPrivateData(command) {
        for (let [cmd, args] of this.subCommands(command))
            if (cmd.privateData)
                return !callable(cmd.privateData) ? cmd.privateData
                                                  : cmd.privateData(args);
        return false;
    },

    // TODO: should it handle comments?
    //     : it might be nice to be able to specify that certain quoting
    //     should be disabled E.g. backslash without having to resort to
    //     using literal etc.
    //     : error messages should be configurable or else we can ditch
    //     Vim compatibility but it actually gives useful messages
    //     sometimes rather than just "Invalid arg"
    //     : I'm not sure documenting the returned object here, and
    //     elsewhere, as type Args rather than simply Object makes sense,
    //     especially since it is further augmented for use in
    //     Command#action etc.
    /**
     * Parses *str* for options and plain arguments.
     *
     * The returned *Args* object is an augmented array of arguments.
     * Any key/value pairs of *extra* will be available and the
     * following additional properties:
     *     -opt       - the value of the option -opt if specified
     *     string     - the original argument string *str*
     *     literalArg - any trailing literal argument
     *
     * Quoting rules:
     *     '-quoted strings   - only ' and \ itself are escaped
     *     "-quoted strings   - also ", \n and \t are translated
     *     non-quoted strings - everything is taken literally apart from "\
     *                          " and "\\"
     *
     * @param {string} str The Ex command-line string to parse. E.g.
     *     "-x=foo -opt=bar arg1 arg2"
     * @param {[CommandOption]} options The options accepted. These are specified
     *      as an array of {@link CommandOption} structures.
     * @param {string} argCount The number of arguments accepted.
     *            "0": no arguments
     *            "1": exactly one argument
     *            "+": one or more arguments
     *            "*": zero or more arguments (default if unspecified)
     *            "?": zero or one arguments
     * @param {boolean} allowUnknownOptions Whether unspecified options
     *     should cause an error.
     * @param {number} literal The index at which any literal arg begins.
     *     See {@link Command#literal}.
     * @param {CompletionContext} complete The relevant completion context
     *     when the args are being parsed for completion.
     * @param {Object} extra Extra keys to be spliced into the returned
     *     Args object.
     * @returns {Args}
     */
    parseArgs: function parseArgs(str, params={}) {
        const self = this;

        function getNextArg(str, _keepQuotes=keepQuotes) {
            if (str.substr(0, 2) === "<<" && hereDoc) {
                let arg = /^<<(\S*)/.exec(str)[1];
                let count = arg.length + 2;
                if (complete)
                    return [count, "", ""];
                return [count, self.readHeredoc(arg), ""];
            }

            let [count, arg, quote] = Commands.parseArg(str, null, _keepQuotes);
            if (quote == "\\" && !complete)
                return [, , , _("error.trailingCharacters", "\\")];
            if (quote && !complete)
                return [, , , _("error.missingQuote", quote)];
            return [count, arg, quote];
        }

        try {

            let count, arg, quote, error;
            var { allowUnknownOptions, argCount, complete, extra, hereDoc, literal, options, keepQuotes } = params;

            if (!options)
                options = [];

            if (!argCount)
                argCount = "*";

            var args = params.newArgs ? params.newArgs() : [];
            args.string = str; // for access to the unparsed string

            // FIXME!
            for (let [k, v] of iter(extra || {}))
                args[k] = v;

            // FIXME: best way to specify these requirements?
            var onlyArgumentsRemaining = allowUnknownOptions || options.length == 0; // after a -- has been found
            arg = null;
            var i = 0;
            var completeOpts;

            // XXX
            let matchOpts = function matchOpts(arg) {
                // Push possible option matches into completions
                if (complete && !onlyArgumentsRemaining)
                    completeOpts = options.filter(opt => (opt.multiple || !hasOwnProp(args, opt.names[0])));
            };
            let resetCompletions = function resetCompletions() {
                completeOpts = null;
                args.completeArg = null;
                args.completeOpt = null;
                args.completeFilter = null;
                args.completeStart = i;
                args.quote = Commands.complQuote[""];
            };
            if (complete) {
                resetCompletions();
                matchOpts("");
                args.completeArg = 0;
            }

            let fail = function fail(error) {
                if (complete)
                    complete.message = error;
                else
                    util.assert(false, error);
            };

            outer:
            while (i < str.length || complete) {
                var argStart = i;
                let re = /\s*/gy;
                re.lastIndex = i;
                i += re.exec(str)[0].length;

                if (str[i] == "|") {
                    args.string = str.slice(0, i);
                    args.trailing = str.slice(i + 1);
                    break;
                }
                if (i == str.length && !complete)
                    break;

                if (complete)
                    resetCompletions();

                var sub = str.substr(i);
                if ((!onlyArgumentsRemaining) && /^--(\s|$)/.test(sub)) {
                    onlyArgumentsRemaining = true;
                    i += 2;
                    continue;
                }

                var optname = "";
                if (!onlyArgumentsRemaining) {
                    for (let opt of options) {
                        for (let optname of opt.names) {
                            if (sub.startsWith(optname)) {
                                let count = 0;
                                let invalid = false;
                                let arg, quote, quoted;

                                let sep = sub[optname.length];
                                let argString = sub.substr(optname.length + 1);
                                if (sep == "=" || /\s/.test(sep) && opt.type != CommandOption.NOARG) {
                                    [count, quoted, quote, error] = getNextArg(argString, true);
                                    arg = Option.dequote(quoted);
                                    util.assert(!error, error);

                                    // if we add the argument to an option after a space, it MUST not be empty
                                    if (sep != "=" && !quote && arg.length == 0 && !complete)
                                        arg = null;

                                    count++; // to compensate the "=" character
                                }
                                else if (!/\s/.test(sep) && sep != undefined) // this isn't really an option as it has trailing characters, parse it as an argument
                                    invalid = true;

                                let context = null;
                                if (!complete && quote)
                                    fail(_("command.invalidOptArg", optname, argString));

                                if (!invalid) {
                                    if (complete && !/[\s=]/.test(sep))
                                        matchOpts(sub);

                                    if (complete && count > 0) {
                                        args.completeStart += optname.length + 1;
                                        args.completeOpt = opt;
                                        args.completeFilter = arg;
                                        args.quote = Commands.complQuote[quote] || Commands.complQuote[""];
                                    }
                                    if (!complete || arg != null) {
                                        if (opt.type) {
                                            let orig = arg;
                                            arg = opt.type.parse(arg, quoted);

                                            if (complete && isArray(arg)) {
                                                args.completeFilter = arg[arg.length - 1] || "";
                                                args.completeStart += orig.length - args.completeFilter.length;
                                            }

                                            if (arg == null || (typeof arg == "number" && isNaN(arg))) {
                                                if (!complete || orig != "" || args.completeStart != str.length)
                                                    fail(_("command.invalidOptTypeArg", opt.type.description, optname, quoted));
                                                if (complete)
                                                    complete.highlight(args.completeStart, count - 1, "SPELLCHECK");
                                            }
                                        }

                                        // we have a validator function
                                        if (typeof opt.validator == "function") {
                                            if (opt.validator(arg, quoted) == false && (arg || !complete)) {
                                                fail(_("command.invalidOptArg", optname, quoted));
                                                if (complete) // Always true.
                                                    complete.highlight(args.completeStart, count - 1, "SPELLCHECK");
                                            }
                                        }
                                    }

                                    if (arg != null || opt.type == CommandOption.NOARG) {
                                        // option allowed multiple times
                                        if (opt.multiple)
                                            args[opt.names[0]] = (args[opt.names[0]] || []).concat(arg);
                                        else
                                            Class.replaceProperty(args, opt.names[0], opt.type == CommandOption.NOARG || arg);

                                        args.explicitOpts[opt.names[0]] = args[opt.names[0]];
                                    }

                                    i += optname.length + count;
                                    if (i == str.length)
                                        break outer;
                                    continue outer;
                                }
                                // if it is invalid, just fall through and try the next argument
                            }
                        }
                    }
                }

                matchOpts(sub);

                if (complete)
                    if (argCount == "0" || args.length > 0 && (/[1?]/.test(argCount)))
                        complete.highlight(i, sub.length, "SPELLCHECK");

                if (args.length === literal) {
                    if (complete)
                        args.completeArg = args.length;

                    let re = /(?:\s*(?=\n)|\s*)([^]*)/gy;
                    re.lastIndex = argStart || 0;
                    sub = re.exec(str)[1];

                    // Hack.
                    if (sub.substr(0, 2) === "<<" && hereDoc) {
                        let [count, arg] = getNextArg(sub);
                        sub = arg + sub.substr(count);
                    }

                    args.push(sub);
                    args.quote = null;
                    break;
                }

                // if not an option, treat this token as an argument
                [count, arg, quote, error] = getNextArg(sub);
                util.assert(!error, error);

                if (complete) {
                    args.quote = Commands.complQuote[quote] || Commands.complQuote[""];
                    args.completeFilter = arg || "";
                }
                else if (count == -1)
                    fail(_("command.parsing", arg));
                else if (!onlyArgumentsRemaining && sub[0] === "-")
                    fail(_("command.invalidOpt", arg));

                if (arg != null)
                    args.push(arg);
                if (complete)
                    args.completeArg = args.length - 1;

                i += count;
                if (count <= 0 || i == str.length)
                    break;
            }

            if (complete && args.trailing == null) {
                if (args.completeOpt) {
                    let opt = args.completeOpt;
                    let context = complete.fork(opt.names[0], args.completeStart);
                    let arg = args.explicitOpts[opt.names[0]];
                    context.filter = args.completeFilter;

                    if (isArray(arg))
                        context.filters.push(item => arg.indexOf(item.text) === -1);

                    if (typeof opt.completer == "function")
                        var compl = opt.completer(context, args);
                    else
                        compl = opt.completer || [];

                    context.title = [opt.names[0]];
                    context.quote = args.quote;
                    if (compl)
                        context.completions = compl;
                }
                complete.advance(args.completeStart);
                complete.keys = {
                    text: "names",
                    description: function (opt) {
                        return messages.get(["command",
                                             params.name,
                                             "options",
                                             opt.names[0],
                                             "description"].join("."),
                                            opt.description);
                    }
                };
                complete.title = ["Options"];
                if (completeOpts)
                    complete.completions = completeOpts;
            }

            if (args.verify)
                args.verify();

            return args;
        }
        catch (e if complete && e instanceof FailedAssertion) {
            complete.message = e;
            return args;
        }
    },

    nameRegexp: util.regexp(String.raw`
            [^
                0-9
                <forbid>
            ]
            [^ <forbid> ]*
        `, "gx", {
        forbid: util.regexp(String.replace(String.raw`
            U0000-U002c // U002d -
            U002e-U002f
            U003a-U0040 // U0041-U005a a-z
            U005b-U0060 // U0061-U007a A-Z
            U007b-U00bf
            U02b0-U02ff // Spacing Modifier Letters
            U0300-U036f // Combining Diacritical Marks
            U1dc0-U1dff // Combining Diacritical Marks Supplement
            U2000-U206f // General Punctuation
            U20a0-U20cf // Currency Symbols
            U20d0-U20ff // Combining Diacritical Marks for Symbols
            U2400-U243f // Control Pictures
            U2440-U245f // Optical Character Recognition
            U2500-U257f // Box Drawing
            U2580-U259f // Block Elements
            U2700-U27bf // Dingbats
            Ufe20-Ufe2f // Combining Half Marks
            Ufe30-Ufe4f // CJK Compatibility Forms
            Ufe50-Ufe6f // Small Form Variants
            Ufe70-Ufeff // Arabic Presentation Forms-B
            Uff00-Uffef // Halfwidth and Fullwidth Forms
            Ufff0-Uffff // Specials
        `, /U/g, "\\u"), "x")
    }),

    validName: Class.Memoize(function validName() {
        return util.regexp("^" + this.nameRegexp.source + "$");
    }),

    commandRegexp: Class.Memoize(function commandRegexp() {
        return util.regexp(String.raw`
            ^
            (?P<spec>
                (?P<prespace> [:\s]*)
                (?P<count>    (?:\d+ | %)? )
                (?P<fullCmd>
                    (?: (?P<group> <name>) : )?
                    (?P<cmd>      (?:-? [()] | <name> | !)? ))
                (?P<bang>     !?)
                (?P<space>    \s*)
            )
            (?P<args>
                (?:. | \n)*?
            )?
            $
        `, "x", {
            name: this.nameRegexp
        });
    }),

    /**
     * Parses a complete Ex command.
     *
     * The parsed string is returned as an Array like
     * [count, command, bang, args]:
     *     count   - any count specified
     *     command - the Ex command name
     *     bang    - whether the special "bang" version was called
     *     args    - the commands full argument string
     * E.g. ":2foo! bar" -> [2, "foo", true, "bar"]
     *
     * @param {string} str The Ex command line string.
     * @returns {Array}
     */
    // FIXME: why does this return an Array rather than Object?
    parseCommand: function parseCommand(str) {
        // remove comments
        str.replace(/\s*".*$/, "");

        let matches = this.commandRegexp.exec(str);
        if (!matches)
            return [];

        let { spec, count, group, cmd, bang, space, args } = matches;
        if (!cmd && bang)
            [cmd, bang] = [bang, cmd];

        if (!cmd || args && args[0] != "|" && !(space || cmd == "!"))
            return [];

        // parse count
        if (count)
            count = count == "%" ? this.COUNT_ALL : parseInt(count, 10);
        else
            count = this.COUNT_NONE;

        return [count, cmd, !!bang, args || "", spec.length, group];
    },

    parseCommands: function* parseCommands(str, complete) {
        const { contexts } = this.modules;
        do {
            let [count, cmd, bang, args, len, group] = commands.parseCommand(str);
            if (!group)
                var command = this.get(cmd || "");
            else if (group = contexts.getGroup(group, "commands"))
                command = group.get(cmd || "");

            if (command == null) {
                yield [null, { commandString: str }];
                return;
            }

            if (complete)
                var context = complete.fork(command.name).fork("opts", len);

            if (!complete || /(\w|^)[!\s]/.test(str))
                args = command.parseArgs(args, context, { count: count, bang: bang });
            else
                args = this.parseArgs(args, { extra: { count: count, bang: bang } });
            args.context = this.context;
            args.commandName = cmd;
            args.commandString = str.substr(0, len) + args.string;
            str = args.trailing;
            yield [command, args];
            if (args.break)
                break;
        }
        while (str);
    },

    subCommands: function* subCommands(command) {
        let commands = [command];
        while (command = commands.shift())
            try {
                for (let [command, args] of this.parseCommands(command)) {
                    if (command) {
                        yield [command, args];
                        if (command.subCommand && args[command.subCommand])
                            commands.push(args[command.subCommand]);
                    }
                }
            }
            catch (e) {}
    },

    /** @property */
    get complQuote() { return Commands.complQuote; },

    /** @property */
    get quoteArg() { return Commands.quoteArg; } // XXX: better somewhere else?

}, {
    // returns [count, parsed_argument]
    parseArg: function parseArg(str, sep, keepQuotes) {
        let arg = "";
        let quote = null;
        let len = str.length;

        function fixEscapes(str) {
            return str.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}|(.))/g,
                               (m, n1) => n1 || m);
        }

        // Fix me.
        if (isString(sep))
            sep = RegExp(sep);

        sep = (sep != null ? sep : /\s/).source;

        if (sep == "(?:)")
            sep = "";

        if (sep == "" || sep == "(?:)")
            var re1 = /^(?!)/;
        else
            re1 = RegExp("^" + sep);

        let re2 = RegExp(/^()((?:[^\\S"']|\\.)+)((?:\\$)?)/.source.replace("S", sep));

        while (str.length && !re1.test(str)) {
            let res;
            if ((res = re2.exec(str)))
                arg += keepQuotes ? res[0] : res[2].replace(/\\(.)/g, "$1");
            else if ((res = /^(")((?:[^\\"]|\\.)*)("?)/.exec(str)))
                arg += keepQuotes ? res[0] : JSON.parse(fixEscapes(res[0]) + (res[3] ? "" : '"'));
            else if ((res = /^(')((?:[^']|'')*)('?)/.exec(str)))
                arg += keepQuotes ? res[0] : res[2].replace(/''/g, "'");
            else
                break;

            if (!res[3])
                quote = res[1];
            if (!res[1])
                quote = res[3];
            str = str.substr(res[0].length);
        }

        return [len - str.length, arg, quote];
    },

    quote: function quote(str) {
        return Commands.quoteArg[
            /[\b\f\n\r\t]/.test(str)   ? '"' :
            /[\s"'\\]|^$|^-/.test(str) ? "'"
                                       : ""](str);
    }
}, {
    completion: function initCompletion(dactyl, modules, window) {
        const { completion, contexts } = modules;

        completion.command = function command(context, group) {
            context.title = ["Command"];
            context.keys = { text: "longNames", description: "description" };
            if (group)
                context.generate = () => group._list;
            else
                context.generate = () => modules.commands.hives.map(h => h._list).flatten();
        };

        // provides completions for ex commands, including their arguments
        completion.ex = function ex(context) {
            const { commands } = modules;

            // if there is no space between the command name and the cursor
            // then get completions of the command name
            for (var [command, args] of commands.parseCommands(context.filter, context))
                if (args.trailing)
                    context.advance(args.commandString.length + 1);

            if (!args)
                args = { commandString: context.filter };

            let match = commands.commandRegexp.exec(args.commandString);
            if (!match)
                return;

            if (match.group)
                context.advance(match.group.length + 1);

            context.advance(match.prespace.length + match.count.length);
            if (!(match.bang || match.space)) {
                context.fork("", 0, this, "command", match.group && contexts.getGroup(match.group, "commands"));
                return;
            }

            // dynamically get completions as specified with the command's completer function
            context.highlight();
            if (!command) {
                context.message = _("command.noSuch", match.cmd);
                context.highlight(0, match.cmd.length, "SPELLCHECK");
                return;
            }

            let cmdContext = context.fork(command.name + "/args", match.fullCmd.length + match.bang.length + match.space.length);
            try {
                if (!cmdContext.waitingForTab) {
                    if (!args.completeOpt && command.completer && args.completeStart != null) {
                        cmdContext.advance(args.completeStart);
                        cmdContext.quote = args.quote;
                        cmdContext.filter = args.completeFilter;
                        command.completer.call(command, cmdContext, args);
                    }
                }
            }
            catch (e) {
                util.reportError(e);
                cmdContext.message = _("error.error", e);
            }
        };

        completion.exMacro = function exMacro(context, args, cmd) {
            if (!cmd.action.macro)
                return;
            let { macro } = cmd.action;

            let start = "«%-d-]'", end = "'[-d-%»";

            let n = /^\d+$/.test(cmd.argCount) ? parseInt(cmd.argCount) : 12;
            for (let i = args.completeArg; i < n; i++)
                args[i] = start + i + end;

            let params = {
                args: {
                    __proto__: args,
                    toString: function () { return this.join(" "); }
                },
                bang:  args.bang ? "!" : "",
                count: args.count
            };

            if (!macro.valid(params))
                return;

            let str = macro(params);
            let idx = str.indexOf(start);
            if (!~idx || !/^(')?(\d+)'/.test(str.substr(idx + start.length))
                    || RegExp.$2 != args.completeArg)
                return;

            let quote = RegExp.$2;
            context.quote = null;
            context.offset -= idx;
            context.filter = str.substr(0, idx) + (quote ? Option.quote : identity)(context.filter);

            context.fork("ex", 0, completion, "ex");
        };

        completion.userCommand = function userCommand(context, group) {
            context.title = ["User Command", "Definition"];
            context.keys = { text: "name", description: "replacementText" };
            context.completions = group || modules.commands.user;
        };
    },

    commands: function initCommands(dactyl, modules, window) {
        const { commands, contexts } = modules;

        commands.add(["(", "-("], "",
            function () { dactyl.echoerr(_("dactyl.cheerUp")); },
            { hidden: true });
        commands.add([")", "-)"], "",
            function () { dactyl.echoerr(_("dactyl.somberDown")); },
            { hidden: true });

        commands.add(["com[mand]"],
            "List or define commands",
            function (args) {
                let cmd = args[0];

                util.assert(!cmd || cmd.split(",").every(commands.validName.bound.test),
                            _("command.invalidName", cmd));

                if (args.length <= 1)
                    commands.list(cmd, args.explicitOpts["-group"] ? [args["-group"]] : null);
                else {
                    util.assert(args["-group"].modifiable,
                                _("group.cantChangeBuiltin", _("command.commands")));

                    let completer = args["-complete"];
                    let completerFunc = function (context, args) {
                        return modules.completion.exMacro(context, args, this);
                    };

                    if (completer) {
                        if (/^custom,/.test(completer)) {
                            completer = completer.substr(7);

                            if (contexts.context)
                                var ctxt = update({}, contexts.context || {});
                            completerFunc = function (context) {
                                var result = contexts.withSavedValues(["context"], function () {
                                    contexts.context = ctxt;
                                    return dactyl.userEval(completer);
                                });
                                if (callable(result))
                                    return result.apply(this, arguments);
                                else
                                    return context.completions = result;
                            };
                        }
                        else
                            completerFunc = context => modules.completion.bound[config.completers[completer]](context);
                    }

                    let added = args["-group"].add(cmd.split(","),
                                    args["-description"],
                                    contexts.bindMacro(args, "-ex",
                                        function makeParams(args, modifiers) {
                                            return {
                                                args: {
                                                    __proto__: args,
                                                    toString: function () {
                                                        return this.string;
                                                    }
                                                },
                                                bang:  this.bang && args.bang ? "!" : "",
                                                count: this.count && args.count
                                            };
                                        }),
                                    {
                                        argCount: args["-nargs"],
                                        bang: args["-bang"],
                                        count: args["-count"],
                                        completer: completerFunc,
                                        literal: args["-literal"],
                                        persist: !args["-nopersist"],
                                        replacementText: args.literalArg,
                                        context: contexts.context && update({}, contexts.context)
                                    }, args.bang);

                    if (!added)
                        dactyl.echoerr(_("command.exists"));
                }
            }, {
                bang: true,
                completer: function (context, args) {
                    const { completion } = modules;
                    if (args.completeArg == 0)
                        completion.userCommand(context, args["-group"]);
                    else
                        args["-javascript"] ? completion.javascript(context) : completion.ex(context);
                },
                hereDoc: true,
                options: [
                    { names: ["-bang", "-b"],  description: "Command may be followed by a !" },
                    { names: ["-count", "-c"], description: "Command may be preceded by a count" },
                    {
                        // TODO: "E180: invalid complete value: " + arg
                        names: ["-complete", "-C"],
                        description: "The argument completion function",
                        completer: function (context) {
                            return Object.entries(config.completers)
                                         .map(([k, v]) => [k, ""]);
                        },
                        type: CommandOption.STRING,
                        validator: function (arg) {
                            return arg in config.completers || /^custom,/.test(arg);
                        }
                    },
                    {
                        names: ["-description", "-desc", "-d"],
                        description: "A user-visible description of the command",
                        default: "User-defined command",
                        type: CommandOption.STRING
                    },
                    contexts.GroupFlag("commands"),
                    {
                        names: ["-javascript", "-js", "-j"],
                        description: "Execute the definition as JavaScript rather than Ex commands"
                    },
                    {
                        names: ["-literal", "-l"],
                        description: "Process the specified argument ignoring any quoting or meta characters",
                        type: CommandOption.INT
                    },
                    {
                        names: ["-nargs", "-a"],
                        description: "The allowed number of arguments",
                        completer: [["0", "No arguments are allowed (default)"],
                                    ["1", "One argument is allowed"],
                                    ["*", "Zero or more arguments are allowed"],
                                    ["?", "Zero or one argument is allowed"],
                                    ["+", "One or more arguments are allowed"]],
                        default: "0",
                        type: CommandOption.STRING,
                        validator: bind("test", /^[01*?+]$/)
                    },
                    {
                        names: ["-nopersist", "-n"],
                        description: "Do not save this command to an auto-generated RC file"
                    }
                ],
                literal: 1,

                serialize: function () {
                    return commands.userHives
                        .filter(h => h.persist)
                        .flatMap(hive =>
                            Array.from(hive)
                                 .filter(cmd => cmd.persist)
                                 .map(cmd =>
                            ({
                                command: this.name,
                                bang: true,
                                options: Ary.toObject(
                                    Object.entries({
                                        argCount: "-nargs",
                                        bang: "-bang",
                                        count: "-count",
                                        description: "-description",
                                    })
                                    .filter(([k, v]) => cmd[k])
                                    .map(([k, v]) => [
                                        v,
                                        typeof cmd[k] == "boolean" ? null : cmd[k]
                                    ])),
                                arguments: [cmd.name],
                                literalArg: cmd.action,
                                ignoreDefaults: true
                            }))
                        );
                }
            });

        commands.add(["delc[ommand]"],
            "Delete the specified user-defined command",
            function (args) {
                util.assert(args.bang ^ !!args[0], _("error.argumentOrBang"));
                let name = args[0];

                if (args.bang)
                    args["-group"].clear();
                else if (args["-group"].get(name))
                    args["-group"].remove(name);
                else
                    dactyl.echoerr(_("command.noSuchUser", name));
            }, {
                argCount: "?",
                bang: true,
                completer: function (context, args) {
                    modules.completion.userCommand(context, args["-group"]);
                },
                options: [contexts.GroupFlag("commands")]
            });

        commands.add(["comp[letions]"],
            "List the completion results for a given command substring",
            function (args) {
                modules.completion.listCompleter("ex", args[0]);
            }, {
                argCount: "1",
                completer: function (context) {
                    modules.completion.ex(context);
                },
                literal: 0
            });

        dactyl.addUsageCommand({
            name: ["listc[ommands]", "lc"],
            description: "List all Ex commands along with their short descriptions",
            index: "ex-cmd",
            iterate: function (args) {
                return commands.iterator().map(cmd => ({
                    __proto__: cmd,
                    columns: [
                        cmd.hive == commands.builtin ? "" : ["span", { highlight: "Object", style: "padding-right: 1em;" },
                                                                cmd.hive.name]
                    ]
                }));
            },
            iterateIndex: function (args) {
                let tags = help.tags;
                return this.iterate(args).filter(cmd => (cmd.hive === commands.builtin ||
                                                         hasOwnProp(tags, cmd.helpTag)));
            },
            format: {
                headings: ["Command", "Group", "Description"],
                description: function (cmd) {
                    return template.linkifyHelp(cmd.description + (cmd.replacementText ? ": " + cmd.action : ""));
                },
                help: function (cmd) { return ":" + cmd.name; }
            }
        });

        commands.add(["y[ank]"],
            "Yank the output of the given command to the clipboard",
            function (args) {
                let cmd = /^:/.test(args[0]) ? args[0] : ":echo " + args[0];

                let res = modules.commandline.withOutputToString(commands.execute, commands, cmd);

                dactyl.clipboardWrite(res);

                let lines = res.split("\n").length;
                dactyl.echomsg(_("command.yank.yankedLine" + (lines == 1 ? "" : "s"), lines));
            }, {
                argCount: "1",
                completer: function (context) {
                    modules.completion[/^:/.test(context.filter) ? "ex" : "javascript"](context);
                },
                literal: 0
            });
    },
    javascript: function initJavascript(dactyl, modules, window) {
        const { setCompleter } = modules.JavaScript;

        setCompleter([CommandHive.prototype.get,
                      CommandHive.prototype.remove],
                     [function () {
                         return Array.from(this, cmd => [c.names, c.description]);
                     }]);
        setCompleter([Commands.prototype.get],
                     [function () {
                         return Array.from(this.iterator(), cmd => [c.names, c.description]);
                     }]);
    },
    mappings: function initMappings(dactyl, modules, window) {
        const { commands, mappings, modes } = modules;

        mappings.add([modes.COMMAND],
            ["@:"], "Repeat the last Ex command",
            function ({ count }) {
                if (commands.repeat) {
                    for (let i of util.interruptibleRange(0, Math.max(count, 1), 100))
                        dactyl.execute(commands.repeat);
                }
                else
                    dactyl.echoerr(_("command.noPrevious"));
            },
            { count: true });
    }
});

let quote = function quote(q, list, map=Commands.quoteMap) {
    let re = RegExp("[" + list + "]", "g");
    function quote(str) {
        return (q + String.replace(str, re, $0 => ($0 in map ? map[$0] : ("\\" + $0)))
                  + q);
    }
    quote.list = list;
    return quote;
};

Commands.quoteMap = {
    "\n": "\\n",
    "\t": "\\t"
};

Commands.quoteArg = {
    '"': quote('"', '\n\t"\\\\'),
    "'": quote("'", "'", { "'": "''" }),
    "":  quote("",  "|\\\\\\s'\"")
};
Commands.complQuote = {
    '"': ['"', quote("", Commands.quoteArg['"'].list), '"'],
    "'": ["'", quote("", Commands.quoteArg["'"].list), "'"],
    "":  ["", Commands.quoteArg[""], ""]
};

Commands.parseBool = arg => {
    if (/^(true|1|on)$/i.test(arg))
        return true;
    if (/^(false|0|off)$/i.test(arg))
        return false;
    return NaN;
};

endModule();

} catch(e){ if (!e.stack) e = Error(e); dump(e.fileName+":"+e.lineNumber+": "+e+"\n" + e.stack); }

// vim: set fdm=marker sw=4 sts=4 ts=8 et ft=javascript:
