adobe/brackets

View on GitHub
src/preferences/PreferencesBase.js

Summary

Maintainability
F
6 days
Test Coverage
/*
 * Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

/*global appshell */
/*unittests: Preferences Base */

/**
 * Infrastructure for the preferences system.
 *
 * At the top, the level at which most people will interact, is the `PreferencesSystem` object.
 * The most common operation is `get(id)`, which simply retrieves the value of a given preference.
 *
 * The PreferencesSystem has a collection of Scopes, which it traverses in a specified order.
 * Each Scope holds one level of settings.
 *
 * PreferencesManager.js sets up a singleton PreferencesSystem that has the following Scopes:
 *
 * * default (the default values for any settings that are explicitly registered)
 * * user (the user's customized settings – the equivalent of Brackets' old
 *   localStorage-based system. This is the settings file that lives in AppData)
 * * Additional scopes for each .brackets.json file going upward in the file tree from the
 *   current file
 *
 * For example, if spaceUnits has a value set in a .brackets.json file near the open file,
 * then a call to get("spaceUnits") would return the value from that file. File values come
 * first, user values next, default values last. If the setting is not known
 * at all, undefined is returned.
 *
 * Each Scope has an associated Storage object that knows how to load and
 * save the preferences value for that Scope. There are two implementations:
 * MemoryStorage and FileStorage.
 *
 * The final concept used is that of Layers, which can be added to Scopes. Generally, a Layer looks
 * for a collection of preferences that are nested in some fashion in the Scope's
 * data. Under certain circumstances (decided upon by the Layer object),
 * those nested preferences will take precedence over the main preferences in the Scope.
 */
define(function (require, exports, module) {
    "use strict";

    var FileUtils       = require("file/FileUtils"),
        FileSystem      = require("filesystem/FileSystem"),
        FileSystemError = require("filesystem/FileSystemError"),
        EventDispatcher = require("utils/EventDispatcher"),
        _               = require("thirdparty/lodash"),
        Async           = require("utils/Async"),
        globmatch       = require("thirdparty/globmatch");

    // CONSTANTS
    var PREFERENCE_CHANGE = "change",
        SCOPEORDER_CHANGE = "scopeOrderChange";

    /*
     * Storages manage the loading and saving of preference data.
     */

    /**
     * MemoryStorage, as the name implies, stores the preferences in memory.
     * This is suitable for single session data or testing.
     *
     * @constructor
     * @param {?Object} data Initial data for the storage.
     */
    function MemoryStorage(data) {
        this.data = data || {};
    }

    MemoryStorage.prototype = {

        /**
         * *Synchronously* returns the data stored in this storage.
         * The original object (not a clone) is returned.
         *
         * @return {Promise} promise that is already resolved
         */
        load: function () {
            var result = new $.Deferred();
            result.resolve(this.data);
            return result.promise();
        },

        /**
         * *Synchronously* saves the data to this storage. This saves
         * the `newData` object reference without cloning it.
         *
         * @param {Object} newData The data to store.
         * @return {Promise} promise that is already resolved
         */
        save: function (newData) {
            var result = new $.Deferred();
            this.data = newData;
            result.resolve();
            return result.promise();
        },

        /**
         * MemoryStorage is not stored in a file, so fileChanged is ignored.
         *
         * @param {string} filePath File that has changed
         */
        fileChanged: function (filePath) {
        }
    };

    // MemoryStorage never actually dispatches change events, but Storage interface requires implementing on()/off()
    EventDispatcher.makeEventDispatcher(MemoryStorage.prototype);


    /**
     * Error type for problems parsing preference files.
     *
     * @constructor
     * @param {string} message Error message
     */
    function ParsingError(message) {
        this.name = "ParsingError";
        this.message = message || "";
    }

    ParsingError.prototype = new Error();


    /**
     * Loads/saves preferences from a JSON file on disk.
     *
     * @constructor
     * @param {string}  path        Path to the preferences file
     * @param {boolean} createIfMissing True if the file should be created if it doesn't exist.
     *                              If this is not true, an exception will be thrown if the
     *                              file does not exist.
     * @param {boolean} recreateIfInvalid True if the file needs to be recreated if it is invalid.
     *                              Invalid- Either unreadable or unparseable.
     *                              The invalid copy will be sent to trash in case the user wants to refer to it.
     */
    function FileStorage(path, createIfMissing, recreateIfInvalid) {
        this.path = path;
        this.createIfMissing = createIfMissing;
        this.recreateIfInvalid = recreateIfInvalid;
        this._lineEndings = FileUtils.getPlatformLineEndings();
    }

    FileStorage.prototype = {

        /**
         * Loads the preferences from disk. Can throw an exception if the file is not
         * readable or parseable.
         *
         * @return {Promise} Resolved with the data once it has been parsed.
         */
        load: function () {
            var result = new $.Deferred();
            var path = this.path;
            var createIfMissing = this.createIfMissing;
            var recreateIfInvalid = this.recreateIfInvalid;
            var self = this;

            if (path) {
                var prefFile = FileSystem.getFileForPath(path);
                prefFile.read({}, function (err, text) {
                    if (err) {
                        if (createIfMissing) {
                            // Unreadable file is also unwritable -- delete so get recreated
                            if (recreateIfInvalid && (err === FileSystemError.NOT_READABLE || err === FileSystemError.UNSUPPORTED_ENCODING)) {
                                appshell.fs.moveToTrash(path, function (err) {
                                    if (err) {
                                        console.log("Cannot move unreadable preferences file " + path + " to trash!!");
                                    } else {
                                        console.log("Brackets has recreated the unreadable preferences file " + path + ". You may refer to the deleted file in trash in case you need it!!");
                                    }
                                }.bind(this));
                            }
                            result.resolve({});
                        } else {
                            result.reject(new Error("Unable to load preferences at " + path + " " + err));
                        }
                        return;
                    }

                    self._lineEndings = FileUtils.sniffLineEndings(text);

                    // If the file is empty, turn it into an empty object
                    if (/^\s*$/.test(text)) {
                        result.resolve({});
                    } else {
                        try {
                            result.resolve(JSON.parse(text));
                        } catch (e) {
                            if (recreateIfInvalid) {
                                // JSON parsing error -- recreate the preferences file
                                appshell.fs.moveToTrash(path, function (err) {
                                    if (err) {
                                        console.log("Cannot move unparseable preferences file " + path + " to trash!!");
                                    } else {
                                        console.log("Brackets has recreated the Invalid JSON preferences file " + path + ". You may refer to the deleted file in trash in case you need it!!");
                                    }
                                }.bind(this));
                                result.resolve({});
                            } else {
                                result.reject(new ParsingError("Invalid JSON settings at " + path + "(" + e.toString() + ")"));
                            }
                        }
                    }
                });
            } else {
                result.resolve({});
            }

            return result.promise();
        },

        /**
         * Saves the new data to disk.
         *
         * @param {Object} newData data to save
         * @return {Promise} Promise resolved (with no arguments) once the data has been saved
         */
        save: function (newData) {
            var result = new $.Deferred();
            var path = this.path;
            var prefFile = FileSystem.getFileForPath(path);

            if (path) {
                try {
                    var text = JSON.stringify(newData, null, 4);

                    // maintain the original line endings
                    text = FileUtils.translateLineEndings(text, this._lineEndings);
                    prefFile.write(text, {}, function (err) {
                        if (err) {
                            result.reject("Unable to save prefs at " + path + " " + err);
                        } else {
                            result.resolve();
                        }
                    });
                } catch (e) {
                    result.reject("Unable to convert prefs to JSON" + e.toString());
                }
            } else {
                result.resolve();
            }
            return result.promise();
        },

        /**
         * Changes the path to the preferences file.
         * This sends a "changed" event to listeners, regardless of whether
         * the path has changed.
         *
         * @param {string} newPath location of this settings file
         */
        setPath: function (newPath) {
            this.path = newPath;
            this.trigger("changed");
        },

        /**
         * If the filename matches this Storage's path, a changed message is triggered.
         *
         * @param {string} filePath File that has changed
         */
        fileChanged: function (filePath) {
            if (filePath === this.path) {
                this.trigger("changed");
            }
        }
    };

    EventDispatcher.makeEventDispatcher(FileStorage.prototype);


    /**
     * A `Scope` is a data container that is tied to a `Storage`.
     *
     * Additionally, `Scope`s support "layers" which are additional levels of preferences
     * that are stored within a single preferences file.
     *
     * @constructor
     * @param {Storage} storage Storage object from which prefs are loaded/saved
     */
    function Scope(storage) {
        this.storage = storage;
        storage.on("changed", this.load.bind(this));
        this.data = {};
        this._dirty = false;
        this._layers = [];
        this._layerMap = {};
        this._exclusions = [];
    }

    _.extend(Scope.prototype, {
        /**
         * Loads the prefs for this `Scope` from the `Storage`.
         *
         * @return {Promise} Promise that is resolved once loading is complete
         */
        load: function () {
            var result = new $.Deferred();
            this.storage.load()
                .then(function (data) {
                    var oldKeys = this.getKeys();
                    this.data = data;
                    result.resolve();
                    this.trigger(PREFERENCE_CHANGE, {
                        ids: _.union(this.getKeys(), oldKeys)
                    });
                }.bind(this))
                .fail(function (error) {
                    result.reject(error);
                });
            return result.promise();
        },

        /**
         * Saves the prefs for this `Scope`.
         *
         * @return {Promise} promise resolved once the data is saved.
         */
        save: function () {
            var self = this;
            if (this._dirty) {
                self._dirty = false;
                return this.storage.save(this.data);
            } else {
                return (new $.Deferred()).resolve().promise();
            }
        },

        /**
         * Sets the value for `id`. The value is set at the location given, or at the current
         * location for the preference if no location is specified. If an invalid location is
         * given, nothing will be set and no exception is thrown.
         *
         * @param {string} id Key to set
         * @param {*} value Value for this key
         * @param {Object=} context Optional additional information about the request (typically used for layers)
         * @param {{layer: ?string, layerID: ?Object}=} location Optional location in which to set the value.
         *                                                      If the object is empty, the value will be
         *                                                      set at the Scope's base level.
         * @return {boolean} true if the value was set
         */
        set: function (id, value, context, location) {
            if (!location) {
                location = this.getPreferenceLocation(id, context);
            }
            if (location && location.layer) {
                var layer = this._layerMap[location.layer];
                if (layer) {
                    if (this.data[layer.key] === undefined) {
                        this.data[layer.key] = {};
                    }

                    var wasSet = layer.set(this.data[layer.key], id, value, context, location.layerID);
                    this._dirty = this._dirty || wasSet;
                    return wasSet;
                } else {
                    return false;
                }
            } else {
                return this._performSet(id, value);
            }
        },

        /**
         * @private
         *
         * Performs the set operation on this Scope's data, deleting the given ID if
         * the new value is undefined. The dirty flag will be set as well.
         *
         * @param {string} id key to set or delete
         * @param {*} value value for this key (undefined to delete)
         * @return {boolean} true if the value was set.
         */
        _performSet: function (id, value) {
            if (!_.isEqual(this.data[id], value)) {
                this._dirty = true;
                if (value === undefined) {
                    delete this.data[id];
                } else {
                    this.data[id] = _.cloneDeep(value);
                }
                return true;
            }
            return false;
        },

        /**
         * Get the value for id, given the context. The context is provided to layers
         * which may override the value from the main data of the Scope. Note that
         * layers will often exclude values from consideration.
         *
         * @param {string} id Preference to retrieve
         * @param {?Object} context Optional additional information about the request
         * @return {*} Current value of the Preference
         */
        get: function (id, context) {
            var layerCounter,
                layers = this._layers,
                layer,
                data = this.data,
                result;

            context = context || {};

            for (layerCounter = 0; layerCounter < layers.length; layerCounter++) {
                layer = layers[layerCounter];
                result = layer.get(data[layer.key], id, context);
                if (result !== undefined) {
                    return result;
                }
            }

            if (this._exclusions.indexOf(id) === -1) {
                return data[id];
            }
        },

        /**
         * Get the location in this Scope (if any) where the given preference is set.
         *
         * @param {string} id Name of the preference for which the value should be retrieved
         * @param {Object=} context Optional context object to change the preference lookup
         * @return {{layer: ?string, layerID: ?object}|undefined} Object describing where the preferences came from.
         *                                              An empty object means that it was defined in the Scope's
         *                                              base data. Undefined means the pref is not
         *                                              defined in this Scope.
         */
        getPreferenceLocation: function (id, context) {
            var layerCounter,
                layers = this._layers,
                layer,
                data = this.data,
                result;

            context = context || {};

            for (layerCounter = 0; layerCounter < layers.length; layerCounter++) {
                layer = layers[layerCounter];
                result = layer.getPreferenceLocation(data[layer.key], id, context);
                if (result !== undefined) {
                    return {
                        layer: layer.key,
                        layerID: result
                    };
                }
            }

            if (this._exclusions.indexOf(id) === -1 && data[id] !== undefined) {
                // The value is defined in this Scope, which means we need to return an
                // empty object as a signal to the PreferencesSystem that this pref
                // is defined in this Scope (in the base data)
                return {};
            }

            // return undefined when this Scope does not have the requested pref
            return undefined;
        },

        /**
         * Get the preference IDs that are set in this Scope. All layers are added
         * in. If context is not provided, the set of all keys in the Scope including
         * all keys in each layer will be returned.
         *
         * @param {?Object} context Optional additional information for looking up the keys
         * @return {Array.<string>} Set of preferences set by this Scope
         */
        getKeys: function (context) {
            context = context || {};

            var layerCounter,
                layers = this._layers,
                layer,
                data = this.data;

            var keySets = [_.difference(_.keys(data), this._exclusions)];
            for (layerCounter = 0; layerCounter < layers.length; layerCounter++) {
                layer = layers[layerCounter];
                keySets.push(layer.getKeys(data[layer.key], context));
            }

            return _.union.apply(null, keySets);
        },

        /**
         * Adds a Layer to this Scope. The Layer object should define a `key`, which
         * represents the subset of the preference data that the Layer works with.
         * Layers should also define `get` and `getKeys` operations that are like their
         * counterparts in Scope but take "data" as the first argument.
         *
         * Listeners are notified of potential changes in preferences with the addition of
         * this layer.
         *
         * @param {Layer} layer Layer object to add to this Scope
         */
        addLayer: function (layer) {
            this._layers.push(layer);
            this._layerMap[layer.key] = layer;
            this._exclusions.push(layer.key);
            this.trigger(PREFERENCE_CHANGE, {
                ids: layer.getKeys(this.data[layer.key], {})
            });
        },

        /**
         * Tells the Scope that the given file has been changed so that the
         * Storage can be reloaded if needed.
         *
         * @param {string} filePath File that has changed
         */
        fileChanged: function (filePath) {
            this.storage.fileChanged(filePath);
        },

        /**
         * Determines if there are likely to be any changes based on the change
         * of context.
         *
         * @param {{path: string, language: string}} oldContext Old context
         * @param {{path: string, language: string}} newContext New context
         * @return {Array.<string>} List of changed IDs
         */
        contextChanged: function (oldContext, newContext) {
            var changes = [],
                data    = this.data;

            _.each(this._layers, function (layer) {
                if (data[layer.key] && oldContext[layer.key] !== newContext[layer.key]) {
                    var changesInLayer = layer.contextChanged(data[layer.key],
                                                              oldContext,
                                                              newContext);
                    if (changesInLayer) {
                        changes.push(changesInLayer);
                    }
                }
            });
            return _.union.apply(null, changes);
        }
    });

    EventDispatcher.makeEventDispatcher(Scope.prototype);


    // Utility functions for the PathLayer

    /**
     * @private
     *
     * Look for a matching file glob among the collection of paths.
     *
     * @param {Object} pathData The keys are globs and the values are the preferences for that glob
     * @param {string} filename relative filename to match against the globs
     * @return {?string} glob pattern that matched, if any
     */
    function _findMatchingGlob(pathData, filename) {
        var globs = Object.keys(pathData),
            globCounter;

        if (!filename) {
            return;
        }

        for (globCounter = 0; globCounter < globs.length; globCounter++) {
            var glob = globs[globCounter];

            if (globmatch(filename, glob)) {
                return glob;
            }
        }
    }

    /**
     * Create a default project layer object that has a single property "key"
     * with "project" as its value.
     *
     * @constructor
     */
    function ProjectLayer() {
        this.projectPath = null;
    }

    ProjectLayer.prototype = {
        key: "project",

        /**
         * Retrieve the current value based on the current project path
         * in the layer.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         */
        get: function (data, id) {
            if (!data || !this.projectPath) {
                return;
            }

            if (data[this.projectPath] && (data[this.projectPath][id] !== undefined)) {
                return data[this.projectPath][id];
            }
            return;
        },

        /**
         * Gets the location in which the given pref was set, if it was set within
         * this project layer for the current project path.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @return {string} the Layer ID, in this case the current project path.
         */
        getPreferenceLocation: function (data, id) {
            if (!data || !this.projectPath) {
                return;
            }

            if (data[this.projectPath] && (data[this.projectPath][id] !== undefined)) {
                return this.projectPath;
            }

            return;
        },

        /**
         * Sets the preference value in the given data structure for the layerID provided. If no
         * layerID is provided, then the current project path is used. If a layerID is provided
         * and it does not exist, it will be created.
         *
         * This function returns whether or not a value was set.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {Object} value new value to assign to the preference
         * @param {Object} context Object with scope and layer key-value pairs (not yet used in project layer)
         * @param {string=} layerID Optional: project path to be used for setting value
         * @return {boolean} true if the value was set
         */
        set: function (data, id, value, context, layerID) {
            if (!layerID) {
                layerID = this.getPreferenceLocation(data, id);
            }

            if (!layerID) {
                return false;
            }

            var section = data[layerID];
            if (!section) {
                data[layerID] = section = {};
            }
            if (!_.isEqual(section[id], value)) {
                if (value === undefined) {
                    delete section[id];
                } else {
                    section[id] = _.cloneDeep(value);
                }
                return true;
            }
            return false;
        },

        /**
         * Retrieves the keys provided by this layer object.
         *
         * @param {Object} data the preference data from the Scope
         */
        getKeys: function (data) {
            if (!data) {
                return;
            }

            return _.union.apply(null, _.map(_.values(data), _.keys));
        },

        /**
         * Set the project path to be used as the layer ID of this layer object.
         *
         * @param {string} projectPath Path of the project root
         */
        setProjectPath: function (projectPath) {
            this.projectPath = projectPath;
        }
    };


    /**
     * @constructor
     *
     * Create a language layer object. Language Layer is completely stateless, it
     * only knows how look up and process prefs set in the language layer. Desired
     * language id should be specified in the "language" field of the context.
     */
    function LanguageLayer() {
    }

    LanguageLayer.prototype = {
        key: "language",

        /**
         * Retrieve the current value based on the specified context. If the context
         * does contain language field, undefined is returned.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {{language: string}} context Context to operate with
         * @return {*|undefined} property value
         */
        get: function (data, id, context) {
            if (!data || !context.language) {
                return;
            }

            if (data[context.language] && (data[context.language][id] !== undefined)) {
                return data[context.language][id];
            }
            return;
        },

        /**
         * Gets the location in which the given pref was set, if it was set within
         * this language layer for the current language.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {{language: string}} context Context to operate with
         * @return {string|undefined} the Layer ID, in this case the current language
         */
        getPreferenceLocation: function (data, id, context) {
            if (!data || !context.language) {
                return;
            }

            if (data[context.language] && (data[context.language][id] !== undefined)) {
                return context.language;
            }

            return;
        },
        /**
         * Retrieves the keys provided by this layer object. If the context is
         * empty, it will return all the keys provided in all the layerIDs
         * (languages).
         *
         * @param {Object} data the preference data from the Scope
         * @param {{language: string}} context Context to operate with
         * @return {Array<{string}>|undefined} An array of pref ids
         */
        getKeys: function (data, context) {
            if (!data) {
                return;
            }

            // do not upset other layers if context for the this one is not specified
            if (!_.isEmpty(context)) {
                if (data[context.language]) {
                    return _.keys(data[context.language]);
                } else {
                    return [];
                }
            } else {
                return _.union.apply(null, _.map(_.values(data), _.keys));
            }
        },

        /**
         * Sets the preference value in the given data structure for the layerID
         * provided. If no layerID is provided, then it will be determined using
         * getPreferenceLocation. If a layerID is located, but it does not
         * exist, it will be created.
         *
         * This function returns whether or not a value was set.
         *
         * @param {Object} data The preference data from the Scope
         * @param {string} id Preference ID to look up
         * @param {Object} value New value to assign to the preference
         * @param {{language: string}} context Context to operate with
         * @param {string=} layerID Language to be used for setting value
         * @return {boolean} True if the value was set
         */
        set: function (data, id, value, context, layerID) {
            if (!layerID) {
                layerID = this.getPreferenceLocation(data, id, context);
            }

            if (!layerID) {
                return false;
            }

            var section = data[layerID];
            if (!section) {
                data[layerID] = section = {};
            }
            if (!_.isEqual(section[id], value)) {
                if (value === undefined) {
                    delete section[id];
                    if (_.isEmpty(section)) {
                        delete data[layerID];
                    }
                } else {
                    section[id] = _.cloneDeep(value);
                }
                return true;
            }
            return false;
        },

        /**
         * Determines if there are preference IDs that could change as a result
         * of the context change. This implementation considers only changes in
         * language.
         *
         * @param {Object} data Data in the Scope
         * @param {{language: string}} oldContext Old context
         * @param {{language: string}} newContext New context
         * @return {Array.<string>|undefined} list of preference IDs that could have changed
         */
        contextChanged: function (data, oldContext, newContext) {
            // this function is called only if the language has changed
            if (newContext.language === undefined) {
                return _.keys(data[oldContext.language]);
            }
            if (oldContext.language === undefined) {
                return _.keys(data[newContext.language]);
            }

            return _.union(_.keys(data[newContext.language]), _.keys(data[oldContext.language]));
        }
    };

    /**
     * Provides layered preferences based on file globs, generally following the model provided
     * by [EditorConfig](http://editorconfig.org/). In usage, it looks something like this
     * (switching to single line comments because the glob interferes with the multiline comment):
     */
//    "path": {
//        "src/thirdparty/CodeMirror/**/*.js": {
//            "spaceUnits": 2,
//            "linting.enabled": false
//        }
//    }

    /**
     * There can be multiple paths and they are each checked in turn. The first that matches the
     * currently edited file wins.
     *
     * @constructor
     * @param {string} prefFilePath path to the preference file
     */
    function PathLayer(prefFilePath) {
        this.setPrefFilePath(prefFilePath);
    }

    PathLayer.prototype = {
        key: "path",

        /**
         * Retrieve the current value based on the filename in the context
         * object, comparing globs relative to the prefFilePath that this
         * PathLayer was set up with.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {Object} context Object with filename that will be compared to the globs
         */
        get: function (data, id, context) {
            var glob = this.getPreferenceLocation(data, id, context);

            if (!glob) {
                return;
            }

            return data[glob][id];
        },

        /**
         * Gets the location in which the given pref was set, if it was set within
         * this path layer for the current path.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {Object} context Object with filename that will be compared to the globs
         * @return {string} the Layer ID, in this case the glob that matched
         */
        getPreferenceLocation: function (data, id, context) {
            if (!data) {
                return;
            }

            var relativeFilename = FileUtils.getRelativeFilename(this.prefFilePath, context[this.key]);
            if (!relativeFilename) {
                return;
            }

            return _findMatchingGlob(data, relativeFilename);
        },

        /**
         * Sets the preference value in the given data structure for the layerID provided. If no
         * layerID is provided, then the current layer is used. If a layerID is provided and it
         * does not exist, it will be created.
         *
         * This function returns whether or not a value was set.
         *
         * @param {Object} data the preference data from the Scope
         * @param {string} id preference ID to look up
         * @param {Object} value new value to assign to the preference
         * @param {Object} context Object with filename that will be compared to the globs
         * @param {string=} layerID Optional: glob pattern for a specific section to set the value in
         * @return {boolean} true if the value was set
         */
        set: function (data, id, value, context, layerID) {
            if (!layerID) {
                layerID = this.getPreferenceLocation(data, id, context);
            }

            if (!layerID) {
                return false;
            }

            var section = data[layerID];
            if (!section) {
                data[layerID] = section = {};
            }
            if (!_.isEqual(section[id], value)) {
                if (value === undefined) {
                    delete section[id];
                } else {
                    section[id] = _.cloneDeep(value);
                }
                return true;
            }
            return false;
        },

        /**
         * Retrieves the keys provided by this layer object. If context with a filename is provided,
         * only the keys for the matching file glob are given. Otherwise, all keys for all globs
         * are provided.
         *
         * @param {Object} data the preference data from the Scope
         * @param {?Object} context Additional context data (filename in particular is important)
         */
        getKeys: function (data, context) {
            if (!data) {
                return;
            }

            var relativeFilename = FileUtils.getRelativeFilename(this.prefFilePath, context[this.key]);

            if (relativeFilename) {
                var glob = _findMatchingGlob(data, relativeFilename);
                if (glob) {
                    return _.keys(data[glob]);
                } else {
                    return [];
                }
            }
            return _.union.apply(null, _.map(_.values(data), _.keys));
        },

        /**
         * Changes the preference file path.
         *
         * @param {string} prefFilePath New path to the preferences file
         */
        setPrefFilePath: function (prefFilePath) {
            if (!prefFilePath) {
                this.prefFilePath = "/";
            } else {
                this.prefFilePath = FileUtils.getDirectoryPath(prefFilePath);
            }
        },

        /**
         * Determines if there are preference IDs that could change as a result of
         * a change in the context. This implementation considers only the path portion
         * of the context and looks up matching globes if any.
         *
         * @param {Object} data Data in the Scope
         * @param {{path: string}} oldContext Old context
         * @param {{path: string}} newContext New context
         * @return {Array.<string>} list of preference IDs that could have changed
         */
        contextChanged: function (data, oldContext, newContext) {
            var newGlob = _findMatchingGlob(data,
                              FileUtils.getRelativeFilename(this.prefFilePath, newContext[this.key])),
                oldGlob = _findMatchingGlob(data,
                              FileUtils.getRelativeFilename(this.prefFilePath, oldContext[this.key]));

            if (newGlob === oldGlob) {
                return;
            }
            if (newGlob === undefined) {
                return _.keys(data[oldGlob]);
            }
            if (oldGlob === undefined) {
                return _.keys(data[newGlob]);
            }

            return _.union(_.keys(data[oldGlob]), _.keys(data[newGlob]));
        }
    };


    /**
     * Represents a single, known Preference.
     *
     * @constructor
     * @param {Object} properties Information about the Preference that is stored on this object
     */
    function Preference(properties) {
        _.extend(this, properties);
    }

    EventDispatcher.makeEventDispatcher(Preference.prototype);


    /**
     * Utility for PreferencesSystem & PrefixedPreferencesSystem -- attach EventDispatcher's on()/off()
     * implementation as private _on_internal()/_off_internal() methods, so the custom on()/off() APIs
     * these classes use can leverage EventDispatcher code internally. Also attach the regular public trigger().
     */
    function _addEventDispatcherImpl(proto) {
        var temp = {};
        EventDispatcher.makeEventDispatcher(temp);
        proto._on_internal  = temp.on;
        proto._off_internal = temp.off;
        proto.trigger       = temp.trigger;
    }

    /**
     * Provides a subset of the PreferencesSystem functionality with preference
     * access always occurring with the given prefix.
     *
     * @constructor
     * @param {PreferencesSystem} base The real PreferencesSystem that is backing this one
     * @param {string} prefix Prefix that is used for preferences lookup. Any separator characters should already be added.
     */
    function PrefixedPreferencesSystem(base, prefix) {
        this.base = base;
        this.prefix = prefix;
        this._listenerInstalled = false;
    }

    PrefixedPreferencesSystem.prototype = {
        /**
         * Defines a new (prefixed) preference.
         *
         * @param {string} id unprefixed identifier of the preference. Generally a dotted name.
         * @param {string} type Data type for the preference (generally, string, boolean, number)
         * @param {Object} initial Default value for the preference
         * @param {?{name: string=, description: string=, validator: function=, excludeFromHints: boolean=, keys: object=, values: array=, valueType: string=}} options
         *      Additional options for the pref.
         *      - `options.name`               Name of the preference that can be used in the UI.
         *      - `options.description`        A description of the preference.
         *      - `options.validator`          A function to validate the value of a preference.
         *      - `options.excludeFromHints`   True if you want to exclude a preference from code hints.
         *      - `options.keys`               An object that will hold the child preferences in case the preference type is `object`
         *      - `options.values`             An array of possible values of a preference. It will show up in code hints.
         *      - `options.valueType`          In case the preference type is `array`, `valueType` should hold data type of its elements.
         * @return {Object} The preference object.
         */
        definePreference: function (id, type, initial, options) {
            return this.base.definePreference(this.prefix + id, type, initial, options);
        },

        /**
         * Get the prefixed preference object
         *
         * @param {string} id ID of the pref to retrieve.
         */
        getPreference: function (id) {
            return this.base.getPreference(this.prefix + id);
        },

        /**
         * Gets the prefixed preference
         *
         * @param {string} id Name of the preference for which the value should be retrieved
         * @param {Object=} context Optional context object to change the preference lookup
         */
        get: function (id, context) {
            context = context || {};
            return this.base.get(this.prefix + id, this.base._getContext(context));
        },

        /**
         * Gets the location in which the value of a prefixed preference has been set.
         *
         * @param {string} id Name of the preference for which the value should be retrieved
         * @param {Object=} context Optional context object to change the preference lookup
         * @return {{scope: string, layer: ?string, layerID: ?object}} Object describing where the preferences came from
         */
        getPreferenceLocation: function (id, context) {
            return this.base.getPreferenceLocation(this.prefix + id, context);
        },

        /**
         * Sets the prefixed preference
         *
         * @param {string} id Identifier of the preference to set
         * @param {Object} value New value for the preference
         * @param {{location: ?Object, context: ?Object}=} options Specific location in which to set the value or the context to use when setting the value
         * @param {boolean=} doNotSave True if the preference change should not be saved automatically.
         * @return {valid:  {boolean}, true if no validator specified or if value is valid
         *          stored: {boolean}} true if a value was stored
         */
        set: function (id, value, options, doNotSave) {
            return this.base.set(this.prefix + id, value, options, doNotSave);
        },

        /**
         * @private
         *
         * Listens for events on the base PreferencesSystem to filter down to the
         * events that consumers of this PreferencesSystem would be interested in.
         */
        _installListener: function () {
            if (this._listenerInstalled) {
                return;
            }
            var self = this,
                prefix = this.prefix;

            var onlyWithPrefix = function (id) {
                if (_.startsWith(id, prefix)) {
                    return true;
                }
                return false;
            };

            var withoutPrefix = function (id) {
                return id.substr(prefix.length);
            };

            this.base.on(PREFERENCE_CHANGE, function (e, data) {
                var prefixedIds = data.ids.filter(onlyWithPrefix);

                if (prefixedIds.length > 0) {
                    self.trigger(PREFERENCE_CHANGE, {
                        ids: prefixedIds.map(withoutPrefix)
                    });
                }
            });

            this._listenerInstalled = true;
        },

        /**
         * Sets up a listener for events for this PrefixedPreferencesSystem. Only prefixed events
         * will notify. Optionally, you can set up a listener for a specific preference.
         *
         * @param {string} event Name of the event to listen for
         * @param {string|Function} preferenceID Name of a specific preference or the handler function
         * @param {?Function} handler Handler for the event
         */
        on: function (event, preferenceID, handler) {
            if (typeof preferenceID === "function") {
                handler = preferenceID;
                preferenceID = null;
            }

            if (preferenceID) {
                var pref = this.getPreference(preferenceID);
                pref.on(event, handler);
            } else {
                this._installListener();
                this._on_internal(event, handler);
            }
        },

        /**
         * Turns off the event handlers for a given event, optionally for a specific preference
         * or a specific handler function.
         *
         * @param {string} event Name of the event for which to turn off listening
         * @param {string|Function} preferenceID Name of a specific preference or the handler function
         * @param {?Function} handler Specific handler which should stop being notified
         */
        off: function (event, preferenceID, handler) {
            if (typeof preferenceID === "function") {
                handler = preferenceID;
                preferenceID = null;
            }

            if (preferenceID) {
                var pref = this.getPreference(preferenceID);
                pref.off(event, handler);
            } else {
                this._off_internal(event, handler);
            }
        },

        /**
         * Saves the preferences. If a save is already in progress, a Promise is returned for
         * that save operation.
         *
         * @return {Promise} Resolved when the preferences are done saving.
         */
        save: function () {
            return this.base.save();
        }
    };

    _addEventDispatcherImpl(PrefixedPreferencesSystem.prototype);


    /**
     * PreferencesSystem ties everything together to provide a simple interface for
     * managing the whole prefs system.
     *
     * It keeps track of multiple Scope levels and also manages path-based Scopes.
     *
     * It also provides the ability to register preferences, which gives a fine-grained
     * means for listening for changes and will ultimately allow for automatic UI generation.
     *
     * The contextBuilder is used to construct get/set contexts based on the needs of individual
     * context systems. It can be passed in at construction time or set later.
     *
     * @constructor
     * @param {function=} contextNormalizer function that is passed the context used for get or set to adjust for specific PreferencesSystem behavior
     */
    function PreferencesSystem(contextBuilder) {
        this.contextBuilder = contextBuilder;

        this._knownPrefs = {};
        this._scopes = {
            "default": new Scope(new MemoryStorage())
        };

        this._scopes["default"].load();

        this._defaults = {
            scopeOrder: ["default"],
            _shadowScopeOrder: [{
                id: "default",
                scope: this._scopes["default"],
                promise: (new $.Deferred()).resolve().promise()
            }]
        };

        this._pendingScopes = {};

        this._saveInProgress = false;
        this._nextSaveDeferred = null;

        // The objects that define the different kinds of path-based Scope handlers.
        // Examples could include the handler for .brackets.json files or an .editorconfig
        // handler.
        this._pathScopeDefinitions = {};

        // Names of the files that contain path scopes
        this._pathScopeFilenames = [];

        // Keeps track of cached path scope objects.
        this._pathScopes = {};

        // Keeps track of change events that need to be sent when change events are resumed
        this._changeEventQueue = null;

        var notifyPrefChange = function (id) {
            var pref = this._knownPrefs[id];
            if (pref) {
                pref.trigger(PREFERENCE_CHANGE);
            }
        }.bind(this);

        // When we signal a general change message on this manager, we also signal a change
        // on the individual preference object.
        this.on(PREFERENCE_CHANGE, function (e, data) {
            data.ids.forEach(notifyPrefChange);
        }.bind(this));
    }

    _.extend(PreferencesSystem.prototype, {

        /**
         * Defines a new preference.
         *
         * @param {string} id identifier of the preference. Generally a dotted name.
         * @param {string} type Data type for the preference (generally, string, boolean, number)
         * @param {Object} initial Default value for the preference
         * @param {?{name: string=, description: string=, validator: function=, excludeFromHints: boolean=, keys: object=, values: array=, valueType: string=}} options
         *      Additional options for the pref.
         *      - `options.name`               Name of the preference that can be used in the UI.
         *      - `options.description`        A description of the preference.
         *      - `options.validator`          A function to validate the value of a preference.
         *      - `options.excludeFromHints`   True if you want to exclude a preference from code hints.
         *      - `options.keys`               An object that will hold the child preferences in case the preference type is `object`
         *      - `options.values`             An array of possible values of a preference. It will show up in code hints.
         *      - `options.valueType`          In case the preference type is `array`, `valueType` should hold data type of its elements.
         * @return {Object} The preference object.
         */
        definePreference: function (id, type, initial, options) {
            options = options || {};
            if (this._knownPrefs.hasOwnProperty(id)) {
                throw new Error("Preference " + id + " was redefined");
            }
            var pref = this._knownPrefs[id] = new Preference({
                type: type,
                initial: initial,
                name: options.name,
                description: options.description,
                validator: options.validator,
                excludeFromHints: options.excludeFromHints,
                keys: options.keys,
                values: options.values,
                valueType: options.valueType
            });
            this.set(id, initial, {
                location: {
                    scope: "default"
                }
            });
            return pref;
        },

        /**
         * Get the preference object for the given ID.
         *
         * @param {string} id ID of the pref to retrieve.
         */
        getPreference: function (id) {
            return this._knownPrefs[id];
        },

        /**
         * Returns a clone of all preferences defined.
         *
         * @return {Object}
         */
        getAllPreferences: function () {
            return _.cloneDeep(this._knownPrefs);
        },

        /**
         * @private
         *
         * Adds the scope before the scope specified by before argument.  This
         * function must never be called directly. Use addScope to add scopes to
         * PreferencesSystem, including from within its implementation.
         *
         * @param {string} id Id of the scope to add
         * @param {string} before Id of the scope to add it before
         */
        _pushToScopeOrder: function (id, before) {
            var defaultScopeOrder = this._defaults.scopeOrder,
                index = _.findIndex(defaultScopeOrder, function (id) {
                    return id === before;
                });
            if (index > -1) {
                defaultScopeOrder.splice(index, 0, id);
            } else {
                // error
                throw new Error("Internal error: scope " + before + " should be in the scope order");
            }

        },

        /**
         * @private
         *
         * Tries to add scope to the scopeOrder once it's resolved. It looks up
         * context's _shadowScopeOrder to find an appropriate context to add it
         * before.
         *
         * @param {Object} shadowEntry Shadow entry of the resolved scope
         */
        _tryAddToScopeOrder: function (shadowEntry) {
            var shadowScopeOrder = this._defaults._shadowScopeOrder,
                index = _.findIndex(shadowScopeOrder, function (entry) {
                    return entry === shadowEntry;
                }),
                i = index + 1;

            // Find an appropriate scope of lower priority to add it before
            while (i < shadowScopeOrder.length) {
                if (shadowScopeOrder[i].promise.state() === "pending" ||
                        shadowScopeOrder[i].promise.state() === "resolved") {
                    break;
                }
                i++;
            }
            switch (shadowScopeOrder[i].promise.state()) {
            case "pending":
                // cannot decide now, lookup once pending promise is settled
                shadowScopeOrder[i].promise.always(function () {
                    this._tryAddToScopeOrder(shadowEntry);
                }.bind(this));
                break;
            case "resolved":
                this._pushToScopeOrder(shadowEntry.id, shadowScopeOrder[i].id);
                this.trigger(SCOPEORDER_CHANGE, {
                    id: shadowEntry.id,
                    action: "added"
                });
                this._triggerChange({
                    ids: shadowEntry.scope.getKeys()
                });
                break;
            default:
                throw new Error("Internal error: no scope found to add before. \"default\" is missing?..");
            }

        },

        /**
         * @private
         *
         * Schedules the new Scope to be added the scope order in the specified
         * location once the promise is resolved. Context's _shadowScopeOrder is
         * used to keep track of the order in which the scope should appear. If
         * the scope which should precede this scope fails to load, then
         * _shadowScopeOrder will be searched for the next appropriate context
         * (the first one which is pending or loaded that is before the failed
         * scope). There's always the lowest-priority "default" scope which is
         * loaded and added, it guarantees that a successfully loaded scope will
         * always be added.
         *
         * Adding a Scope "before" another Scope means that the new Scope's
         * preferences will take priority over the "before" Scope's preferences.
         *
         * @param {string} id Name of the new Scope
         * @param {Scope} scope The scope object to add
         * @param {$.Promise} promise Scope's load promise
         * @param {?string} addBefore Name of the Scope before which this new one is added
         */
        _addToScopeOrder: function (id, scope, promise, addBefore) {
            var shadowScopeOrder = this._defaults._shadowScopeOrder,
                shadowEntry,
                index,
                isPending = false,
                self = this;

            scope.on(PREFERENCE_CHANGE + ".prefsys", function (e, data) {
                self._triggerChange(data);
            }.bind(this));

            index = _.findIndex(shadowScopeOrder, function (entry) {
                return entry.id === id;
            });

            if (index > -1) {
                shadowEntry = shadowScopeOrder[index];
            } else {
                /* new scope is being added. */
                shadowEntry = {
                    id: id,
                    promise: promise,
                    scope: scope
                };
                if (!addBefore) {
                    shadowScopeOrder.unshift(shadowEntry);
                } else {
                    index = _.findIndex(shadowScopeOrder, function (entry) {
                        return entry.id === addBefore;
                    });
                    if (index > -1) {
                        shadowScopeOrder.splice(index, 0, shadowEntry);
                    } else {
                        var queue = this._pendingScopes[addBefore];
                        if (!queue) {
                            queue = [];
                            this._pendingScopes[addBefore] = queue;
                        }
                        queue.unshift(shadowEntry);
                        isPending = true;
                    }
                }
            }

            if (!isPending) {
                promise
                    .then(function () {
                        this._scopes[id] = scope;
                        this._tryAddToScopeOrder(shadowEntry);
                    }.bind(this))
                    .fail(function (err) {
                        // clean up all what's been done up to this point
                        _.pull(shadowScopeOrder, shadowEntry);
                    }.bind(this));
                if (this._pendingScopes[id]) {
                    var pending = this._pendingScopes[id];
                    delete this._pendingScopes[id];
                    pending.forEach(function (entry) {
                        this._addToScopeOrder(entry.id, entry.scope, entry.promise, id);
                    }.bind(this));
                }
            }
        },

        /**
         * Adds scope to the scope order by its id. The scope should be previously added to the preference system.
         *
         * @param {string} id the scope id
         * @param {string} before the id of the scope to add before
         *
         */
        addToScopeOrder: function (id, addBefore) {
            var shadowScopeOrder = this._defaults._shadowScopeOrder,
                index = _.findIndex(shadowScopeOrder, function (entry) {
                    return entry.id === id;
                }),
                entry;
            if (index > -1) {
                entry = shadowScopeOrder[index];
                this._addToScopeOrder(entry.id, entry.scope, entry.promise, addBefore);
            }
        },

        /**
         * Removes a scope from the default scope order.
         *
         * @param {string} id Name of the Scope to remove from the default scope order.
         */
        removeFromScopeOrder: function (id) {
            var scope = this._scopes[id];
            if (scope) {
                _.pull(this._defaults.scopeOrder, id);
                scope.off(".prefsys");
                this.trigger(SCOPEORDER_CHANGE, {
                    id: id,
                    action: "removed"
                });
                this._triggerChange({
                    ids: scope.getKeys()
                });
            }
        },

        /**
         * @private
         *
         * Normalizes the context to be one of:
         *
         * 1. a context object that was passed in
         * 2. the default context
         *
         * @param {Object} context Context that was passed in
         * @return {{scopeOrder: string, filename: ?string}} context object
         */
        _getContext: function (context) {
            if (context) {
                if (this.contextBuilder) {
                    context = this.contextBuilder(context);
                }
                if (!context.scopeOrder) {
                    context.scopeOrder = this._defaults.scopeOrder;
                }
                return context;
            }
            return { scopeOrder: this._defaults.scopeOrder };
        },

        /**
         * Adds a new Scope. New Scopes are added at the highest precedence, unless the "before" option
         * is given. The new Scope is automatically loaded.
         *
         * @param {string} id Name of the Scope
         * @param {Scope|Storage} scope the Scope object itself. Optionally, can be given a Storage directly for convenience.
         * @param {{before: string}} options optional behavior when adding (e.g. setting which scope this comes before)
         * @return {Promise} Promise that is resolved when the Scope is loaded. It is resolved
         *                   with id and scope.
         */
        addScope: function (id, scope, options) {
            var promise;
            options = options || {};

            if (this._scopes[id]) {
                throw new Error("Attempt to redefine preferences scope: " + id);
            }

            // Check to see if scope is a Storage that needs to be wrapped
            if (!scope.get) {
                scope = new Scope(scope);
            }

            promise = scope.load();

            this._addToScopeOrder(id, scope, promise, options.before);

            promise
                .fail(function (err) {
                    // With preferences, it is valid for there to be no file.
                    // It is not valid to have an unparseable file.
                    if (err instanceof ParsingError) {
                        console.error(err);
                    }
                });

            return promise;
        },

        /**
         * Removes a Scope from this PreferencesSystem. Returns without doing anything
         * if the Scope does not exist. Notifies listeners of preferences that may have
         * changed.
         *
         * @param {string} id Name of the Scope to remove
         */
        removeScope: function (id) {
            var scope = this._scopes[id],
                shadowIndex;
            if (!scope) {
                return;
            }

            this.removeFromScopeOrder(id);
            shadowIndex = _.findIndex(this._defaults._shadowScopeOrder, function (entry) {
                return entry.id === id;
            });
            this._defaults._shadowScopeOrder.splice(shadowIndex, 1);
            delete this._scopes[id];
        },

        /**
         * @private
         *
         * Retrieves the appropriate scopeOrder based on the given context.
         * If the context contains a scopeOrder, that will be used. If not,
         * the default scopeOrder is used.
         *
         * @param {{scopeOrder: ?Array.<string>} context
         * @return {Array.<string>} list of scopes in the correct order for traversal
         */
        _getScopeOrder: function (context) {
            return context.scopeOrder || this._defaults.scopeOrder;
        },

        /**
         * Get the current value of a preference. The optional context provides a way to
         * change scope ordering or the reference filename for path-based scopes.
         *
         * @param {string} id Name of the preference for which the value should be retrieved
         * @param {Object|string=} context Optional context object or name of context to change the preference lookup
         */
        get: function (id, context) {
            var scopeCounter;

            context = this._getContext(context);

            var scopeOrder = this._getScopeOrder(context);

            for (scopeCounter = 0; scopeCounter < scopeOrder.length; scopeCounter++) {
                var scope = this._scopes[scopeOrder[scopeCounter]];
                if (scope) {
                    var result = scope.get(id, context);
                    if (result !== undefined) {
                        var pref      = this.getPreference(id),
                            validator = pref && pref.validator;
                        if (!validator || validator(result)) {
                            if (pref && pref.type === "object") {
                                result = _.extend({}, pref.initial, result);
                            }
                            return _.cloneDeep(result);
                        }
                    }
                }
            }
        },

        /**
         * Gets the location in which the value of a preference has been set.
         *
         * @param {string} id Name of the preference for which the value should be retrieved
         * @param {Object=} context Optional context object to change the preference lookup
         * @return {{scope: string, layer: ?string, layerID: ?object}} Object describing where the preferences came from
         */
        getPreferenceLocation: function (id, context) {
            var scopeCounter,
                scopeName;

            context = this._getContext(context);

            var scopeOrder = this._getScopeOrder(context);

            for (scopeCounter = 0; scopeCounter < scopeOrder.length; scopeCounter++) {
                scopeName = scopeOrder[scopeCounter];
                var scope = this._scopes[scopeName];
                if (scope) {
                    var result = scope.getPreferenceLocation(id, context);
                    if (result !== undefined) {
                        result.scope = scopeName;
                        return result;
                    }
                }
            }
        },

        /**
         * Sets a preference and notifies listeners that there may
         * have been a change. By default, the preference is set in the same location in which
         * it was defined except for the "default" scope. If the current value of the preference
         * comes from the "default" scope, the new value will be set at the level just above
         * default.
         *
         * @param {string} id Identifier of the preference to set
         * @param {Object} value New value for the preference
         * @param {{location: ?Object, context: ?Object}=} options Specific location in which to set the value or the context to use when setting the value
         * @param {boolean=} doNotSave True if the preference change should not be saved automatically.
         * @return {valid:  {boolean}, true if no validator specified or if value is valid
         *          stored: {boolean}} true if a value was stored
         */
        set: function (id, value, options, doNotSave) {
            options = options || {};
            var context = this._getContext(options.context),

                // The case where the "default" scope was chosen specifically is special.
                // Usually "default" would come up only when a preference did not have any
                // user-set value, in which case we'd want to set the value in a different scope.
                forceDefault = options.location && options.location.scope === "default" ? true : false,
                location = options.location || this.getPreferenceLocation(id, context);

            if (!location || (location.scope === "default" && !forceDefault)) {
                var scopeOrder = this._getScopeOrder(context);

                // The default scope for setting a preference is the lowest priority
                // scope after "default".
                if (scopeOrder.length > 1) {
                    location = {
                        scope: scopeOrder[scopeOrder.length - 2]
                    };
                } else {
                    return { valid: true, stored: false };
                }
            }

            var scope = this._scopes[location.scope];
            if (!scope) {
                return { valid: true, stored: false };
            }

            var pref      = this.getPreference(id),
                validator = pref && pref.validator;
            if (validator && !validator(value)) {
                return { valid: false, stored: false };
            }

            var wasSet = scope.set(id, value, context, location);
            if (wasSet) {
                if (!doNotSave) {
                    this.save();
                }
                this._triggerChange({
                    ids: [id]
                });
            }
            return { valid: true, stored: wasSet };
        },

        /**
         * Saves the preferences. If a save is already in progress, a Promise is returned for
         * that save operation.
         *
         * @return {Promise} Resolved when the preferences are done saving.
         */
        save: function () {
            if (this._saveInProgress) {
                if (!this._nextSaveDeferred) {
                    this._nextSaveDeferred = new $.Deferred();
                }
                return this._nextSaveDeferred.promise();
            }

            var deferred = this._nextSaveDeferred || (new $.Deferred());
            this._saveInProgress = true;
            this._nextSaveDeferred = null;

            Async.doInParallel(_.values(this._scopes), function (scope) {
                if (scope) {
                    return scope.save();
                } else {
                    return (new $.Deferred()).resolve().promise();
                }
            }.bind(this))
                .then(function () {
                    this._saveInProgress = false;
                    if (this._nextSaveDeferred) {
                        this.save();
                    }
                    deferred.resolve();
                }.bind(this))
                .fail(function (err) {
                    deferred.reject(err);
                });

            return deferred.promise();
        },

        /**
         * Signals the context change to all the scopes within the preferences
         * layer.  PreferencesManager is in charge of computing the context and
         * signaling the changes to PreferencesSystem.
         *
         * @param {{path: string, language: string}} oldContext Old context
         * @param {{path: string, language: string}} newContext New context
         */
        signalContextChanged: function (oldContext, newContext) {
            var changes = [];

            _.each(this._scopes, function (scope) {
                var changedInScope = scope.contextChanged(oldContext, newContext);
                if (changedInScope) {
                    changes.push(changedInScope);
                }
            });

            changes = _.union.apply(null, changes);
            if (changes.length > 0) {
                this._triggerChange({
                    ids: changes
                });
            }
        },

        /**
         * Sets up a listener for events. Optionally, you can set up a listener for a
         * specific preference.
         *
         * @param {string} event Name of the event to listen for
         * @param {string|Function} preferenceID Name of a specific preference or the handler function
         * @param {?Function} handler Handler for the event
         */
        on: function (event, preferenceID, handler) {
            if (typeof preferenceID === "function") {
                handler = preferenceID;
                preferenceID = null;
            }

            if (preferenceID) {
                var pref = this.getPreference(preferenceID);
                pref.on(event, handler);
            } else {
                this._on_internal(event, handler);
            }
        },

        /**
         * Turns off the event handlers for a given event, optionally for a specific preference
         * or a specific handler function.
         *
         * @param {string} event Name of the event for which to turn off listening
         * @param {string|Function} preferenceID Name of a specific preference or the handler function
         * @param {?Function} handler Specific handler which should stop being notified
         */
        off: function (event, preferenceID, handler) {
            if (typeof preferenceID === "function") {
                handler = preferenceID;
                preferenceID = null;
            }

            if (preferenceID) {
                var pref = this.getPreference(preferenceID);
                pref.off(event, handler);
            } else {
                this._off_internal(event, handler);
            }
        },

        /**
         * @private
         *
         * Sends a change event to listeners. If change events have been paused (see
         * pauseChangeEvents) then the IDs are queued up.
         *
         * @param {{ids: Array.<string>}} data Message to send
         */
        _triggerChange: function (data) {
            if (this._changeEventQueue) {
                this._changeEventQueue = _.union(this._changeEventQueue, data.ids);
            } else {
                this.trigger(PREFERENCE_CHANGE, data);
            }
        },

        /**
         * Turns off sending of change events, queueing them up for sending once sending is resumed.
         * The events are compacted so that each preference that will be notified is only
         * notified once. (For example, if `spaceUnits` is changed 5 times, only one change
         * event will be sent upon resuming events.)
         */
        pauseChangeEvents: function () {
            if (!this._changeEventQueue) {
                this._changeEventQueue = [];
            }
        },

        /**
         * Turns sending of events back on, sending any events that were queued while the
         * events were paused.
         */
        resumeChangeEvents: function () {
            if (this._changeEventQueue) {
                this.trigger(PREFERENCE_CHANGE, {
                    ids: this._changeEventQueue
                });
                this._changeEventQueue = null;
            }
        },

        /**
         * Tells the PreferencesSystem that the given file has been changed so that any
         * related Scopes can be reloaded.
         *
         * @param {string} filePath File that has changed
         */
        fileChanged: function (filePath) {
            _.forEach(this._scopes, function (scope) {
                scope.fileChanged(filePath);
            });
        },

        /**
         * Retrieves a PreferencesSystem in which all preference access is prefixed.
         * This helps provide namespacing so that different preferences consumers do
         * not interfere with one another.
         *
         * The prefix provided has a `.` character appended when preference lookups are
         * done.
         */
        getPrefixedSystem: function (prefix) {
            return new PrefixedPreferencesSystem(this, prefix + ".");
        }

    });

    _addEventDispatcherImpl(PreferencesSystem.prototype);


    // Public interface
    exports.PreferencesSystem   = PreferencesSystem;
    exports.Scope               = Scope;
    exports.MemoryStorage       = MemoryStorage;
    exports.PathLayer           = PathLayer;
    exports.ProjectLayer        = ProjectLayer;
    exports.LanguageLayer       = LanguageLayer;
    exports.FileStorage         = FileStorage;
});