<commit_before>/***** BEGIN LICENSE BLOCK ***** {{{
Version: MPL 1.1/GPL 2.0/LGPL 2.1

The contents of this file are subject to the Mozilla Public License Version
1.1 (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.mozilla.org/MPL/

Software distributed under the License is distributed on an "AS IS" basis,
WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
for the specific language governing rights and limitations under the
License.

Copyright (c) 2006-2009 by <NAME> <<EMAIL>>

Alternatively, the contents of this file may be used under the terms of
either the GNU General Public License Version 2 or later (the "GPL"), or
the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
in which case the provisions of the GPL or the LGPL are applicable instead
of those above. If you wish to allow use of your version of this file only
under the terms of either the GPL or the LGPL, and not to allow others to
use your version of this file under the terms of the MPL, indicate your
decision by deleting the provisions above and replace them with the notice
and other provisions required by the GPL or the LGPL. If you do not delete
the provisions above, a recipient may use your version of this file under
the terms of any one of the MPL, the GPL or the LGPL.
}}} ***** END LICENSE BLOCK *****/

/** @scope modules */

Cu.import("resource://gre/modules/XPCOMUtils.jsm", modules);

const plugins = { __proto__: modules };
const userContext = { __proto__: modules };

const EVAL_ERROR = "__liberator_eval_error";
const EVAL_RESULT = "__liberator_eval_result";
const EVAL_STRING = "__liberator_eval_string";

const liberator = (function () //{{{
{
    ////////////////////////////////////////////////////////////////////////////////
    ////////////////////// PRIVATE SECTION /////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    function Runnable(self, func, args)
    {
        this.self = self;
        this.func = func;
        this.args = args;
    }
    Runnable.prototype = {
        QueryInterface: XPCOMUtils.generateQI([Ci.nsIRunnable]),
        run: function () { this.func.apply(this.self, this.args); }
    };

    const callbacks = {};
    const observers = {};

    function registerObserver(type, callback)
    {
        if (!(type in observers))
            observers[type] = [];
        observers[type].push(callback);
    }

    let nError = 0;
    function loadModule(name, func)
    {
        let message = "Loading module " + name + "...";
        try
        {
            liberator.log(message, 0);
            liberator.dump(message);
            modules[name] = func();
            liberator.triggerObserver("load_" + name, name);
        }
        catch (e)
        {
            if (nError++ == 0)
                toOpenWindowByType("global:console", "chrome://global/content/console.xul");
            liberator.reportError(e);
        }
    }

    // initially hide all GUI elements, they are later restored unless the user
    // has :set go= or something similar in his config
    function hideGUI()
    {
        let guioptions = config.guioptions;
        for (let option in guioptions)
        {
            guioptions[option].forEach(function (elem) {
                try
                {
                    document.getElementById(elem).collapsed = true;
                }
                catch (e) {}
            });
        }
    }

    // return the platform normalized to Vim values
    function getPlatformFeature()
    {
        let platform = navigator.platform;
        return /^Mac/.test(platform) ? "MacUnix" : platform == "Win32" ? "Win32" : "Unix";
    }

    // TODO: move this
    function getMenuItems()
    {
        function addChildren(node, parent)
        {
            for (let [,item] in Iterator(node.childNodes))
            {
                if (item.childNodes.length == 0 && item.localName == "menuitem"
                    && !/rdf:http:/.test(item.getAttribute("label"))) // FIXME
                {
                    item.fullMenuPath = parent + item.getAttribute("label");
                    items.push(item);
                }
                else
                {
                    let path = parent;
                    if (item.localName == "menu")
                        path += item.getAttribute("label") + ".";
                    addChildren(item, path);
                }
            }
        }

        let items = [];
        addChildren(document.getElementById(config.guioptions["m"][1]), "");
        return items;
    }

    // show a usage index either in the MOW or as a full help page
    function showHelpIndex(tag, items, inMow)
    {
        if (inMow)
            liberator.echo(template.usage(items), commandline.FORCE_MULTILINE);
        else
            liberator.help(tag);
    }

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// OPTIONS /////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    // Only general options are added here, which are valid for all Liberator extensions
    registerObserver("load_options", function () {

        options.add(["errorbells", "eb"],
            "Ring the bell when an error message is displayed",
            "boolean", false);

        options.add(["exrc", "ex"],
            "Allow reading of an RC file in the current directory",
            "boolean", false);

        const groups = {
            config: {
                opts: config.guioptions,
                setter: function (opts)
                {
                    for (let [opt, [,ids]] in Iterator(this.opts))
                    {
                        ids.map(function (id) document.getElementById(id))
                           .forEach(function (elem)
                        {
                            if (elem)
                                elem.collapsed = (opts.indexOf(opt) == -1);
                        });
                    }
                }
            },
            scroll: {
                opts: { r: ["Right Scrollbar", "vertical"], l: ["Left Scrollbar", "vertical"], b: ["Bottom Scrollbar", "horizontal"] },
                setter: function (opts)
                {
                    let dir = ["horizontal", "vertical"].filter(function (dir) !Array.some(opts, function (o) this.opts[o] && this.opts[o][1] == dir, this), this);
                    let class = dir.map(function (dir) "html|html > xul|scrollbar[orient=" + dir + "]");

                    if (class.length)
                        styles.addSheet(true, "scrollbar", "*", class.join(", ") + " { visibility: collapse !important; }", true);
                    else
                        styles.removeSheet(true, "scrollbar");
                    options.safeSetPref("layout.scrollbar.side", opts.indexOf("l") >= 0 ? 3 : 2);
                },
                validator: function (opts) (opts.indexOf("l") < 0 || opts.indexOf("r") < 0)
            },
            tab: {
                opts: {
                    n: ["Tab number", highlight.selector("TabNumber")],
                    N: ["Tab number over icon", highlight.selector("TabIconNumber")]
                },
                setter: function (opts)
                {
                    const self = this;
                    let classes = [v[1] for ([k, v] in Iterator(this.opts)) if (opts.indexOf(k) < 0)];
                    let css = classes.length ? classes.join(",") + "{ display: none; }" : "";
                    styles.addSheet(true, "taboptions", "chrome://*", css);
                    tabs.tabsBound = Array.some(opts, function (k) k in self.opts);
                    statusline.updateTabCount();
                }
            }
        };

        options.add(["fullscreen", "fs"],
            "Show the current window fullscreen",
            "boolean", false,
            {
                setter: function (value) window.fullScreen = value,
                getter: function () window.fullScreen
            });

        options.add(["guioptions", "go"],
            "Show or hide certain GUI elements like the menu or toolbar",
            "charlist", config.defaults.guioptions || "",
            {
                setter: function (value)
                {
                    for (let [,group] in Iterator(groups))
                        group.setter(value);
                    return value;
                },
                completer: function (context)
                {
                    let opts = [v.opts for ([k, v] in Iterator(groups))];
                    opts = opts.map(function (opt) [[k, v[0]] for ([k, v] in Iterator(opt))]);
                    return util.Array.flatten(opts);
                },
                validator: function (val) Option.validateCompleter.call(this, val) &&
                        [v for ([k, v] in Iterator(groups))].every(function (g) !g.validator || g.validator(val))
            });

        options.add(["helpfile", "hf"],
            "Name of the main help file",
            "string", "intro.html");

        options.add(["loadplugins", "lpl"],
            "Load plugin scripts when starting up",
            "boolean", true);

        options.add(["verbose", "vbs"],
            "Define which info messages are displayed",
            "number", 1,
            { validator: function (value) value >= 0 && value <= 15 });

        options.add(["visualbell", "vb"],
            "Use visual bell instead of beeping on errors",
            "boolean", false,
            {
                setter: function (value)
                {
                    options.safeSetPref("accessibility.typeaheadfind.enablesound", !value);
                    return value;
                }
            });
    });

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// MAPPINGS ////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    registerObserver("load_mappings", function () {

        mappings.add(modes.all, ["<F1>"],
            "Open help window",
            function () { liberator.help(); });

        if (liberator.has("session"))
        {
            mappings.add([modes.NORMAL], ["ZQ"],
                "Quit and don't save the session",
                function () { liberator.quit(false); });
        }

        mappings.add([modes.NORMAL], ["ZZ"],
            "Quit and save the session",
            function () { liberator.quit(true); });
    });

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// COMMANDS ////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    registerObserver("load_commands", function () {

        commands.add(["addo[ns]"],
            "Manage available Extensions and Themes",
            function ()
            {
                liberator.open("chrome://mozapps/content/extensions/extensions.xul",
                    (options["newtab"] && options.get("newtab").has("all", "addons"))
                        ? liberator.NEW_TAB: liberator.CURRENT_TAB);
            },
            { argCount: "0" });

        commands.add(["beep"],
            "Play a system beep",
            function () { liberator.beep(); },
            { argCount: "0" });

        commands.add(["dia[log]"],
            "Open a " + config.name + " dialog",
            function (args)
            {
                let arg = args[0];

                try
                {
                    // TODO: why are these sorts of properties arrays? --djk
                    let dialogs = config.dialogs;

                    for (let [,dialog] in Iterator(dialogs))
                    {
                        if (util.compareIgnoreCase(arg, dialog[0]) == 0)
                        {
                            dialog[2]();
                            return;
                        }
                    }

                    liberator.echoerr("E475: Invalid argument: " + arg);
                }
                catch (e)
                {
                    liberator.echoerr("Error opening '" + arg + "': " + e);
                }
            },
            {
                argCount: "1",
                bang: true,
                completer: function (context)
                {
                    context.ignoreCase = true;
                    return completion.dialog(context);
                }
            });

        commands.add(["em[enu]"],
            "Execute the specified menu item from the command line",
            function (args)
            {
                let arg = args.literalArg;
                let items = getMenuItems();

                if (!items.some(function (i) i.fullMenuPath == arg))
                    return void liberator.echoerr("E334: Menu not found: " + arg);

                for (let [,item] in Iterator(items))
                {
                    if (item.fullMenuPath == arg)
                        item.doCommand();
                }
            },
            {
                argCount: "1",
                completer: function (context) completion.menuItem(context),
                literal: 0
            });

        commands.add(["exe[cute]"],
            "Execute the argument as an Ex command",
            // FIXME: this should evaluate each arg separately then join
            // with " " before executing.
            // E.g. :execute "source" io.getRCFile().path
            // Need to fix commands.parseArgs which currently strips the quotes
            // from quoted args
            function (args)
            {
                try
                {
                    let cmd = liberator.eval(args.string);
                    liberator.execute(cmd);
                }
                catch (e)
                {
                    liberator.echoerr(e);
                }
            });

        commands.add(["exta[dd]"],
            "Install an extension",
            function (args)
            {
                let file = io.getFile(args[0]);

                if (file.exists() && file.isReadable() && file.isFile())
                    services.get("extensionManager").installItemFromFile(file, "app-profile");
                else
                {
                    if (file.exists() && file.isDirectory())
                        liberator.echomsg("Cannot install a directory: \"" + file.path + "\"", 0);

                    liberator.echoerr("E484: Can't open file " + file.path);
                }
            },
            {
                argCount: "1",
                completer: function (context) {
                    context.filters.push(function ({ item: f }) f.isDirectory() || /\.xpi$/.test(f.leafName));
                    completion.file(context);
                }
            });

        // TODO: handle extension dependencies
        [
            {
                name: "extde[lete]",
                description: "Uninstall an extension",
                action: "uninstallItem"
            },
            {
                name: "exte[nable]",
                description: "Enable an extension",
                action: "enableItem"
            },
            {
                name: "extd[isable]",
                description: "Disable an extension",
                action: "disableItem"
            }
        ].forEach(function (command) {
            commands.add([command.name],
                command.description,
                function (args)
                {
                    let name = args[0];
                    function action(e) { services.get("extensionManager")[command.action](e.id); };

                    if (args.bang)
                        liberator.extensions.forEach(function (e) { action(e); });
                    else
                    {
                        if (!name)
                            return void liberator.echoerr("E471: Argument required"); // XXX

                        let extension = liberator.getExtension(name);
                        if (extension)
                            action(extension);
                        else
                            liberator.echoerr("E474: Invalid argument");
                    }
                },
                {
                    argCount: "?", // FIXME: should be "1"
                    bang: true,
                    completer: function (context) completion.extension(context)
                });
        });
        commands.add(["exto[ptions]", "extp[references]"],
            "Open an extension's preference dialog",
            function (args)
            {
                let extension = liberator.getExtension(args[0]);
                if (!extension || !extension.options)
                    return void liberator.extension("E474: Invalid argument");
                if (args.bang)
                    window.openDialog(extension.options,
                            "_blank", "chrome");
                else
                    liberator.open(extension.options,
                        options.get("newtab").has("all", "extoptions")
                            ? liberator.NEW_TAB : liberator.CURRENT_TAB);
            },
            {
                argCount: "1",
                bang: true,
                completer: function (context)
                {
                    completion.extension(context);
                    context.filters.push(function ({ item: e }) e.options);
                },
                literal: 0
            });

        // TODO: maybe indicate pending status too?
        commands.add(["extens[ions]"],
            "List available extensions",
            function (args)
            {
                let filter = args[0] || "";
                let extensions = liberator.extensions.filter(function (e) e.name.indexOf(filter) >= 0);

                if (extensions.length > 0)
                {
                    let list = template.tabular(
                        ["Name", "Version", "Status", "Description"], [],
                        ([template.icon(e, e.name),
                          e.version,
                          e.enabled ? <span style="color: blue;">enabled</span>
                                    : <span style="color: red;">disabled</span>,
                          e.description] for ([,e] in Iterator(extensions)))
                    );

                    commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE);
                }
                else
                {
                    if (filter)
                        liberator.echoerr("Exxx: No extension matching \"" + filter + "\"");
                    else<commit_msg>More selective :ext* completion filtering.<commit_after>/***** BEGIN LICENSE BLOCK ***** {{{
Version: MPL 1.1/GPL 2.0/LGPL 2.1

The contents of this file are subject to the Mozilla Public License Version
1.1 (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.mozilla.org/MPL/

Software distributed under the License is distributed on an "AS IS" basis,
WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
for the specific language governing rights and limitations under the
License.

Copyright (c) 2006-2009 by <NAME> <<EMAIL>>

Alternatively, the contents of this file may be used under the terms of
either the GNU General Public License Version 2 or later (the "GPL"), or
the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
in which case the provisions of the GPL or the LGPL are applicable instead
of those above. If you wish to allow use of your version of this file only
under the terms of either the GPL or the LGPL, and not to allow others to
use your version of this file under the terms of the MPL, indicate your
decision by deleting the provisions above and replace them with the notice
and other provisions required by the GPL or the LGPL. If you do not delete
the provisions above, a recipient may use your version of this file under
the terms of any one of the MPL, the GPL or the LGPL.
}}} ***** END LICENSE BLOCK *****/

/** @scope modules */

Cu.import("resource://gre/modules/XPCOMUtils.jsm", modules);

const plugins = { __proto__: modules };
const userContext = { __proto__: modules };

const EVAL_ERROR = "__liberator_eval_error";
const EVAL_RESULT = "__liberator_eval_result";
const EVAL_STRING = "__liberator_eval_string";

const liberator = (function () //{{{
{
    ////////////////////////////////////////////////////////////////////////////////
    ////////////////////// PRIVATE SECTION /////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    function Runnable(self, func, args)
    {
        this.self = self;
        this.func = func;
        this.args = args;
    }
    Runnable.prototype = {
        QueryInterface: XPCOMUtils.generateQI([Ci.nsIRunnable]),
        run: function () { this.func.apply(this.self, this.args); }
    };

    const callbacks = {};
    const observers = {};

    function registerObserver(type, callback)
    {
        if (!(type in observers))
            observers[type] = [];
        observers[type].push(callback);
    }

    let nError = 0;
    function loadModule(name, func)
    {
        let message = "Loading module " + name + "...";
        try
        {
            liberator.log(message, 0);
            liberator.dump(message);
            modules[name] = func();
            liberator.triggerObserver("load_" + name, name);
        }
        catch (e)
        {
            if (nError++ == 0)
                toOpenWindowByType("global:console", "chrome://global/content/console.xul");
            liberator.reportError(e);
        }
    }

    // initially hide all GUI elements, they are later restored unless the user
    // has :set go= or something similar in his config
    function hideGUI()
    {
        let guioptions = config.guioptions;
        for (let option in guioptions)
        {
            guioptions[option].forEach(function (elem) {
                try
                {
                    document.getElementById(elem).collapsed = true;
                }
                catch (e) {}
            });
        }
    }

    // return the platform normalized to Vim values
    function getPlatformFeature()
    {
        let platform = navigator.platform;
        return /^Mac/.test(platform) ? "MacUnix" : platform == "Win32" ? "Win32" : "Unix";
    }

    // TODO: move this
    function getMenuItems()
    {
        function addChildren(node, parent)
        {
            for (let [,item] in Iterator(node.childNodes))
            {
                if (item.childNodes.length == 0 && item.localName == "menuitem"
                    && !/rdf:http:/.test(item.getAttribute("label"))) // FIXME
                {
                    item.fullMenuPath = parent + item.getAttribute("label");
                    items.push(item);
                }
                else
                {
                    let path = parent;
                    if (item.localName == "menu")
                        path += item.getAttribute("label") + ".";
                    addChildren(item, path);
                }
            }
        }

        let items = [];
        addChildren(document.getElementById(config.guioptions["m"][1]), "");
        return items;
    }

    // show a usage index either in the MOW or as a full help page
    function showHelpIndex(tag, items, inMow)
    {
        if (inMow)
            liberator.echo(template.usage(items), commandline.FORCE_MULTILINE);
        else
            liberator.help(tag);
    }

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// OPTIONS /////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    // Only general options are added here, which are valid for all Liberator extensions
    registerObserver("load_options", function () {

        options.add(["errorbells", "eb"],
            "Ring the bell when an error message is displayed",
            "boolean", false);

        options.add(["exrc", "ex"],
            "Allow reading of an RC file in the current directory",
            "boolean", false);

        const groups = {
            config: {
                opts: config.guioptions,
                setter: function (opts)
                {
                    for (let [opt, [,ids]] in Iterator(this.opts))
                    {
                        ids.map(function (id) document.getElementById(id))
                           .forEach(function (elem)
                        {
                            if (elem)
                                elem.collapsed = (opts.indexOf(opt) == -1);
                        });
                    }
                }
            },
            scroll: {
                opts: { r: ["Right Scrollbar", "vertical"], l: ["Left Scrollbar", "vertical"], b: ["Bottom Scrollbar", "horizontal"] },
                setter: function (opts)
                {
                    let dir = ["horizontal", "vertical"].filter(function (dir) !Array.some(opts, function (o) this.opts[o] && this.opts[o][1] == dir, this), this);
                    let class = dir.map(function (dir) "html|html > xul|scrollbar[orient=" + dir + "]");

                    if (class.length)
                        styles.addSheet(true, "scrollbar", "*", class.join(", ") + " { visibility: collapse !important; }", true);
                    else
                        styles.removeSheet(true, "scrollbar");
                    options.safeSetPref("layout.scrollbar.side", opts.indexOf("l") >= 0 ? 3 : 2);
                },
                validator: function (opts) (opts.indexOf("l") < 0 || opts.indexOf("r") < 0)
            },
            tab: {
                opts: {
                    n: ["Tab number", highlight.selector("TabNumber")],
                    N: ["Tab number over icon", highlight.selector("TabIconNumber")]
                },
                setter: function (opts)
                {
                    const self = this;
                    let classes = [v[1] for ([k, v] in Iterator(this.opts)) if (opts.indexOf(k) < 0)];
                    let css = classes.length ? classes.join(",") + "{ display: none; }" : "";
                    styles.addSheet(true, "taboptions", "chrome://*", css);
                    tabs.tabsBound = Array.some(opts, function (k) k in self.opts);
                    statusline.updateTabCount();
                }
            }
        };

        options.add(["fullscreen", "fs"],
            "Show the current window fullscreen",
            "boolean", false,
            {
                setter: function (value) window.fullScreen = value,
                getter: function () window.fullScreen
            });

        options.add(["guioptions", "go"],
            "Show or hide certain GUI elements like the menu or toolbar",
            "charlist", config.defaults.guioptions || "",
            {
                setter: function (value)
                {
                    for (let [,group] in Iterator(groups))
                        group.setter(value);
                    return value;
                },
                completer: function (context)
                {
                    let opts = [v.opts for ([k, v] in Iterator(groups))];
                    opts = opts.map(function (opt) [[k, v[0]] for ([k, v] in Iterator(opt))]);
                    return util.Array.flatten(opts);
                },
                validator: function (val) Option.validateCompleter.call(this, val) &&
                        [v for ([k, v] in Iterator(groups))].every(function (g) !g.validator || g.validator(val))
            });

        options.add(["helpfile", "hf"],
            "Name of the main help file",
            "string", "intro.html");

        options.add(["loadplugins", "lpl"],
            "Load plugin scripts when starting up",
            "boolean", true);

        options.add(["verbose", "vbs"],
            "Define which info messages are displayed",
            "number", 1,
            { validator: function (value) value >= 0 && value <= 15 });

        options.add(["visualbell", "vb"],
            "Use visual bell instead of beeping on errors",
            "boolean", false,
            {
                setter: function (value)
                {
                    options.safeSetPref("accessibility.typeaheadfind.enablesound", !value);
                    return value;
                }
            });
    });

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// MAPPINGS ////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    registerObserver("load_mappings", function () {

        mappings.add(modes.all, ["<F1>"],
            "Open help window",
            function () { liberator.help(); });

        if (liberator.has("session"))
        {
            mappings.add([modes.NORMAL], ["ZQ"],
                "Quit and don't save the session",
                function () { liberator.quit(false); });
        }

        mappings.add([modes.NORMAL], ["ZZ"],
            "Quit and save the session",
            function () { liberator.quit(true); });
    });

    /////////////////////////////////////////////////////////////////////////////}}}
    ////////////////////// COMMANDS ////////////////////////////////////////////////
    /////////////////////////////////////////////////////////////////////////////{{{

    registerObserver("load_commands", function () {

        commands.add(["addo[ns]"],
            "Manage available Extensions and Themes",
            function ()
            {
                liberator.open("chrome://mozapps/content/extensions/extensions.xul",
                    (options["newtab"] && options.get("newtab").has("all", "addons"))
                        ? liberator.NEW_TAB: liberator.CURRENT_TAB);
            },
            { argCount: "0" });

        commands.add(["beep"],
            "Play a system beep",
            function () { liberator.beep(); },
            { argCount: "0" });

        commands.add(["dia[log]"],
            "Open a " + config.name + " dialog",
            function (args)
            {
                let arg = args[0];

                try
                {
                    // TODO: why are these sorts of properties arrays? --djk
                    let dialogs = config.dialogs;

                    for (let [,dialog] in Iterator(dialogs))
                    {
                        if (util.compareIgnoreCase(arg, dialog[0]) == 0)
                        {
                            dialog[2]();
                            return;
                        }
                    }

                    liberator.echoerr("E475: Invalid argument: " + arg);
                }
                catch (e)
                {
                    liberator.echoerr("Error opening '" + arg + "': " + e);
                }
            },
            {
                argCount: "1",
                bang: true,
                completer: function (context)
                {
                    context.ignoreCase = true;
                    return completion.dialog(context);
                }
            });

        commands.add(["em[enu]"],
            "Execute the specified menu item from the command line",
            function (args)
            {
                let arg = args.literalArg;
                let items = getMenuItems();

                if (!items.some(function (i) i.fullMenuPath == arg))
                    return void liberator.echoerr("E334: Menu not found: " + arg);

                for (let [,item] in Iterator(items))
                {
                    if (item.fullMenuPath == arg)
                        item.doCommand();
                }
            },
            {
                argCount: "1",
                completer: function (context) completion.menuItem(context),
                literal: 0
            });

        commands.add(["exe[cute]"],
            "Execute the argument as an Ex command",
            // FIXME: this should evaluate each arg separately then join
            // with " " before executing.
            // E.g. :execute "source" io.getRCFile().path
            // Need to fix commands.parseArgs which currently strips the quotes
            // from quoted args
            function (args)
            {
                try
                {
                    let cmd = liberator.eval(args.string);
                    liberator.execute(cmd);
                }
                catch (e)
                {
                    liberator.echoerr(e);
                }
            });

        commands.add(["exta[dd]"],
            "Install an extension",
            function (args)
            {
                let file = io.getFile(args[0]);

                if (file.exists() && file.isReadable() && file.isFile())
                    services.get("extensionManager").installItemFromFile(file, "app-profile");
                else
                {
                    if (file.exists() && file.isDirectory())
                        liberator.echomsg("Cannot install a directory: \"" + file.path + "\"", 0);

                    liberator.echoerr("E484: Can't open file " + file.path);
                }
            },
            {
                argCount: "1",
                completer: function (context) {
                    context.filters.push(function ({ item: f }) f.isDirectory() || /\.xpi$/.test(f.leafName));
                    completion.file(context);
                }
            });

        // TODO: handle extension dependencies
        [
            {
                name: "extde[lete]",
                description: "Uninstall an extension",
                action: "uninstallItem"
            },
            {
                name: "exte[nable]",
                description: "Enable an extension",
                action: "enableItem",
                filter: function ({ item: e }) !e.enabled
            },
            {
                name: "extd[isable]",
                description: "Disable an extension",
                action: "disableItem",
                filter: function ({ item: e }) e.enabled
            }
        ].forEach(function (command) {
            commands.add([command.name],
                command.description,
                function (args)
                {
                    let name = args[0];
                    function action(e) { services.get("extensionManager")[command.action](e.id); };

                    if (args.bang)
                        liberator.extensions.forEach(function (e) { action(e); });
                    else
                    {
                        if (!name)
                            return void liberator.echoerr("E471: Argument required"); // XXX

                        let extension = liberator.getExtension(name);
                        if (extension)
                            action(extension);
                        else
                            liberator.echoerr("E474: Invalid argument");
                    }
                },
                {
                    argCount: "?", // FIXME: should be "1"
                    bang: true,
                    completer: function (context)
                    {
                        completion.extension(context)
                        if (command.filter)
                            context.filters.push(command.filter);
                    },
                    literal: 0
                });
        });
        commands.add(["exto[ptions]", "extp[references]"],
            "Open an extension's preference dialog",
            function (args)
            {
                let extension = liberator.getExtension(args[0]);
                if (!extension || !extension.options)
                    return void liberator.extension("E474: Invalid argument");
                if (args.bang)
                    window.openDialog(extension.options, "_blank", "chrome");
                else
                    liberator.open(extension.options,
                        options.get("newtab").has("all", "extoptions")
                            ? liberator.NEW_TAB : liberator.CURRENT_TAB);
            },
            {
                argCount: "1",
                bang: true,
                completer: function (context)
                {
                    completion.extension(context);
                    context.filters.push(function ({ item: e }) e.options);
                },
                literal: 0
            });

        // TODO: maybe indicate pending status too?
        commands.add(["extens[ions]"],
            "List available extensions",
            function (args)
            {
                let filter = args[0] || "";
                let extensions = liberator.extensions.filter(function (e) e.name.indexOf(filter) >= 0);

                if (extensions.length > 0)
                {
                    let list = template.tabular(
                        ["Name", "Version", "Status", "Description"], [],
                        ([template.icon(e, e.name),
                          e.version,
                          /* XXX: Inline style. */
                          e.enabled ? <span style="color: blue;">enabled</span>
                                    : <span style="color: red;">disabled</span>,
                          e.description] for ([,e] in Iterator(extensions)))
                    );

                    commandline.echo(list, commandline.HL_NORMAL, commandline.FORCE_MULTILINE);
                }
                else
                {
                    if (filter)
                        liberator.echoerr("Exxx: No extension matching \"" + filter + "\"");
                    else
{'fraction_non_alphanumeric': 0.15832705154531984, 'fraction_numerical': 0.004236957708403733, 'mean_word_length': 1.1895168174833533, 'pattern_counts': {'":': 0, '<': 27, '<?xml version=': 0, '>': 29, 'https://': 0, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3486749', 'n_tokens_mistral': 9612, 'n_tokens_neox': 8925, 'n_words': 2387}