src/config.js

Summary

Maintainability
F
1 wk
Test Coverage
const _ = require('underscore-plus');
const { Emitter } = require('event-kit');
const {
  getValueAtKeyPath,
  setValueAtKeyPath,
  deleteValueAtKeyPath,
  pushKeyPath,
  splitKeyPath
} = require('key-path-helpers');
const Color = require('./color');
const ScopedPropertyStore = require('scoped-property-store');
const ScopeDescriptor = require('./scope-descriptor');

const schemaEnforcers = {};

// Essential: Used to access all of Atom's configuration details.
//
// An instance of this class is always available as the `atom.config` global.
//
// ## Getting and setting config settings.
//
// ```coffee
// # Note that with no value set, ::get returns the setting's default value.
// atom.config.get('my-package.myKey') # -> 'defaultValue'
//
// atom.config.set('my-package.myKey', 'value')
// atom.config.get('my-package.myKey') # -> 'value'
// ```
//
// You may want to watch for changes. Use {::observe} to catch changes to the setting.
//
// ```coffee
// atom.config.set('my-package.myKey', 'value')
// atom.config.observe 'my-package.myKey', (newValue) ->
//   # `observe` calls immediately and every time the value is changed
//   console.log 'My configuration changed:', newValue
// ```
//
// If you want a notification only when the value changes, use {::onDidChange}.
//
// ```coffee
// atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) ->
//   console.log 'My configuration changed:', newValue, oldValue
// ```
//
// ### Value Coercion
//
// Config settings each have a type specified by way of a
// [schema](json-schema.org). For example we might want an integer setting that only
// allows integers greater than `0`:
//
// ```coffee
// # When no value has been set, `::get` returns the setting's default value
// atom.config.get('my-package.anInt') # -> 12
//
// # The string will be coerced to the integer 123
// atom.config.set('my-package.anInt', '123')
// atom.config.get('my-package.anInt') # -> 123
//
// # The string will be coerced to an integer, but it must be greater than 0, so is set to 1
// atom.config.set('my-package.anInt', '-20')
// atom.config.get('my-package.anInt') # -> 1
// ```
//
// ## Defining settings for your package
//
// Define a schema under a `config` key in your package main.
//
// ```coffee
// module.exports =
//   # Your config schema
//   config:
//     someInt:
//       type: 'integer'
//       default: 23
//       minimum: 1
//
//   activate: (state) -> # ...
//   # ...
// ```
//
// See [package docs](http://flight-manual.atom.io/hacking-atom/sections/package-word-count/) for
// more info.
//
// ## Config Schemas
//
// We use [json schema](http://json-schema.org) which allows you to define your value's
// default, the type it should be, etc. A simple example:
//
// ```coffee
// # We want to provide an `enableThing`, and a `thingVolume`
// config:
//   enableThing:
//     type: 'boolean'
//     default: false
//   thingVolume:
//     type: 'integer'
//     default: 5
//     minimum: 1
//     maximum: 11
// ```
//
// The type keyword allows for type coercion and validation. If a `thingVolume` is
// set to a string `'10'`, it will be coerced into an integer.
//
// ```coffee
// atom.config.set('my-package.thingVolume', '10')
// atom.config.get('my-package.thingVolume') # -> 10
//
// # It respects the min / max
// atom.config.set('my-package.thingVolume', '400')
// atom.config.get('my-package.thingVolume') # -> 11
//
// # If it cannot be coerced, the value will not be set
// atom.config.set('my-package.thingVolume', 'cats')
// atom.config.get('my-package.thingVolume') # -> 11
// ```
//
// ### Supported Types
//
// The `type` keyword can be a string with any one of the following. You can also
// chain them by specifying multiple in an an array. For example
//
// ```coffee
// config:
//   someSetting:
//     type: ['boolean', 'integer']
//     default: 5
//
// # Then
// atom.config.set('my-package.someSetting', 'true')
// atom.config.get('my-package.someSetting') # -> true
//
// atom.config.set('my-package.someSetting', '12')
// atom.config.get('my-package.someSetting') # -> 12
// ```
//
// #### string
//
// Values must be a string.
//
// ```coffee
// config:
//   someSetting:
//     type: 'string'
//     default: 'hello'
// ```
//
// #### integer
//
// Values will be coerced into integer. Supports the (optional) `minimum` and
// `maximum` keys.
//
//   ```coffee
//   config:
//     someSetting:
//       type: 'integer'
//       default: 5
//       minimum: 1
//       maximum: 11
//   ```
//
// #### number
//
// Values will be coerced into a number, including real numbers. Supports the
// (optional) `minimum` and `maximum` keys.
//
// ```coffee
// config:
//   someSetting:
//     type: 'number'
//     default: 5.3
//     minimum: 1.5
//     maximum: 11.5
// ```
//
// #### boolean
//
// Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into
// a boolean. Numbers, arrays, objects, and anything else will not be coerced.
//
// ```coffee
// config:
//   someSetting:
//     type: 'boolean'
//     default: false
// ```
//
// #### array
//
// Value must be an Array. The types of the values can be specified by a
// subschema in the `items` key.
//
// ```coffee
// config:
//   someSetting:
//     type: 'array'
//     default: [1, 2, 3]
//     items:
//       type: 'integer'
//       minimum: 1.5
//       maximum: 11.5
// ```
//
// #### color
//
// Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha`
// properties that all have numeric values. `red`, `green`, `blue` will be in
// the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any
// valid CSS color format such as `#abc`, `#abcdef`, `white`,
// `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`.
//
// ```coffee
// config:
//   someSetting:
//     type: 'color'
//     default: 'white'
// ```
//
// #### object / Grouping other types
//
// A config setting with the type `object` allows grouping a set of config
// settings. The group will be visually separated and has its own group headline.
// The sub options must be listed under a `properties` key.
//
// ```coffee
// config:
//   someSetting:
//     type: 'object'
//     properties:
//       myChildIntOption:
//         type: 'integer'
//         minimum: 1.5
//         maximum: 11.5
// ```
//
// ### Other Supported Keys
//
// #### enum
//
// All types support an `enum` key, which lets you specify all the values the
// setting can take. `enum` may be an array of allowed values (of the specified
// type), or an array of objects with `value` and `description` properties, where
// the `value` is an allowed value, and the `description` is a descriptive string
// used in the settings view.
//
// In this example, the setting must be one of the 4 integers:
//
// ```coffee
// config:
//   someSetting:
//     type: 'integer'
//     default: 4
//     enum: [2, 4, 6, 8]
// ```
//
// In this example, the setting must be either 'foo' or 'bar', which are
// presented using the provided descriptions in the settings pane:
//
// ```coffee
// config:
//   someSetting:
//     type: 'string'
//     default: 'foo'
//     enum: [
//       {value: 'foo', description: 'Foo mode. You want this.'}
//       {value: 'bar', description: 'Bar mode. Nobody wants that!'}
//     ]
// ```
//
// If you only have a few elements, you can display your enum as a list of
// radio buttons in the settings view rather than a select list. To do so,
// specify `radio: true` as a sibling property to the `enum` array.
//
// ```coffee
// config:
//   someSetting:
//     type: 'string'
//     default: 'foo'
//     enum: [
//       {value: 'foo', description: 'Foo mode. You want this.'}
//       {value: 'bar', description: 'Bar mode. Nobody wants that!'}
//     ]
//     radio: true
// ```
//
// Usage:
//
// ```coffee
// atom.config.set('my-package.someSetting', '2')
// atom.config.get('my-package.someSetting') # -> 2
//
// # will not set values outside of the enum values
// atom.config.set('my-package.someSetting', '3')
// atom.config.get('my-package.someSetting') # -> 2
//
// # If it cannot be coerced, the value will not be set
// atom.config.set('my-package.someSetting', '4')
// atom.config.get('my-package.someSetting') # -> 4
// ```
//
// #### title and description
//
// The settings view will use the `title` and `description` keys to display your
// config setting in a readable way. By default the settings view humanizes your
// config key, so `someSetting` becomes `Some Setting`. In some cases, this is
// confusing for users, and a more descriptive title is useful.
//
// Descriptions will be displayed below the title in the settings view.
//
// For a group of config settings the humanized key or the title and the
// description are used for the group headline.
//
// ```coffee
// config:
//   someSetting:
//     title: 'Setting Magnitude'
//     description: 'This will affect the blah and the other blah'
//     type: 'integer'
//     default: 4
// ```
//
// __Note__: You should strive to be so clear in your naming of the setting that
// you do not need to specify a title or description!
//
// Descriptions allow a subset of
// [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/).
// Specifically, you may use the following in configuration setting descriptions:
//
// * **bold** - `**bold**`
// * *italics* - `*italics*`
// * [links](https://atom.io) - `[links](https://atom.io)`
// * `code spans` - `` `code spans` ``
// * line breaks - `line breaks<br/>`
// * ~~strikethrough~~ - `~~strikethrough~~`
//
// #### order
//
// The settings view orders your settings alphabetically. You can override this
// ordering with the order key.
//
// ```coffee
// config:
//   zSetting:
//     type: 'integer'
//     default: 4
//     order: 1
//   aSetting:
//     type: 'integer'
//     default: 4
//     order: 2
// ```
//
// ## Manipulating values outside your configuration schema
//
// It is possible to manipulate(`get`, `set`, `observe` etc) values that do not
// appear in your configuration schema. For example, if the config schema of the
// package 'some-package' is
//
// ```coffee
// config:
// someSetting:
//   type: 'boolean'
//   default: false
// ```
//
// You can still do the following
//
// ```coffee
// let otherSetting  = atom.config.get('some-package.otherSetting')
// atom.config.set('some-package.stillAnotherSetting', otherSetting * 5)
// ```
//
// In other words, if a function asks for a `key-path`, that path doesn't have to
// be described in the config schema for the package or any package. However, as
// highlighted in the best practices section, you are advised against doing the
// above.
//
// ## Best practices
//
// * Don't depend on (or write to) configuration keys outside of your keypath.
//
class Config {
  static addSchemaEnforcer(typeName, enforcerFunction) {
    if (schemaEnforcers[typeName] == null) {
      schemaEnforcers[typeName] = [];
    }
    return schemaEnforcers[typeName].push(enforcerFunction);
  }

  static addSchemaEnforcers(filters) {
    for (let typeName in filters) {
      const functions = filters[typeName];
      for (let name in functions) {
        const enforcerFunction = functions[name];
        this.addSchemaEnforcer(typeName, enforcerFunction);
      }
    }
  }

  static executeSchemaEnforcers(keyPath, value, schema) {
    let error = null;
    let types = schema.type;
    if (!Array.isArray(types)) {
      types = [types];
    }
    for (let type of types) {
      try {
        const enforcerFunctions = schemaEnforcers[type].concat(
          schemaEnforcers['*']
        );
        for (let enforcer of enforcerFunctions) {
          // At some point in one's life, one must call upon an enforcer.
          value = enforcer.call(this, keyPath, value, schema);
        }
        error = null;
        break;
      } catch (e) {
        error = e;
      }
    }

    if (error != null) {
      throw error;
    }
    return value;
  }

  // Created during initialization, available as `atom.config`
  constructor(params = {}) {
    this.clear();
    this.initialize(params);
  }

  initialize({ saveCallback, mainSource, projectHomeSchema }) {
    if (saveCallback) {
      this.saveCallback = saveCallback;
    }
    if (mainSource) this.mainSource = mainSource;
    if (projectHomeSchema) {
      this.schema.properties.core.properties.projectHome = projectHomeSchema;
      this.defaultSettings.core.projectHome = projectHomeSchema.default;
    }
  }

  clear() {
    this.emitter = new Emitter();
    this.schema = {
      type: 'object',
      properties: {}
    };

    this.defaultSettings = {};
    this.settings = {};
    this.projectSettings = {};
    this.projectFile = null;

    this.scopedSettingsStore = new ScopedPropertyStore();

    this.settingsLoaded = false;
    this.transactDepth = 0;
    this.pendingOperations = [];
    this.legacyScopeAliases = new Map();
    this.requestSave = _.debounce(() => this.save(), 1);
  }

  /*
  Section: Config Subscription
  */

  // Essential: Add a listener for changes to a given key path. This is different
  // than {::onDidChange} in that it will immediately call your callback with the
  // current value of the config entry.
  //
  // ### Examples
  //
  // You might want to be notified when the themes change. We'll watch
  // `core.themes` for changes
  //
  // ```coffee
  // atom.config.observe 'core.themes', (value) ->
  //   # do stuff with value
  // ```
  //
  // * `keyPath` {String} name of the key to observe
  // * `options` (optional) {Object}
  //   * `scope` (optional) {ScopeDescriptor} describing a path from
  //     the root of the syntax tree to a token. Get one by calling
  //     {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
  //     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  //     for more information.
  // * `callback` {Function} to call when the value of the key changes.
  //   * `value` the new value of the key
  //
  // Returns a {Disposable} with the following keys on which you can call
  // `.dispose()` to unsubscribe.
  observe(...args) {
    let callback, keyPath, options, scopeDescriptor;
    if (args.length === 2) {
      [keyPath, callback] = args;
    } else if (
      args.length === 3 &&
      (_.isString(args[0]) && _.isObject(args[1]))
    ) {
      [keyPath, options, callback] = args;
      scopeDescriptor = options.scope;
    } else {
      console.error(
        'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details'
      );
      return;
    }

    if (scopeDescriptor != null) {
      return this.observeScopedKeyPath(scopeDescriptor, keyPath, callback);
    } else {
      return this.observeKeyPath(
        keyPath,
        options != null ? options : {},
        callback
      );
    }
  }

  // Essential: Add a listener for changes to a given key path. If `keyPath` is
  // not specified, your callback will be called on changes to any key.
  //
  // * `keyPath` (optional) {String} name of the key to observe. Must be
  //   specified if `scopeDescriptor` is specified.
  // * `options` (optional) {Object}
  //   * `scope` (optional) {ScopeDescriptor} describing a path from
  //     the root of the syntax tree to a token. Get one by calling
  //     {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
  //     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  //     for more information.
  // * `callback` {Function} to call when the value of the key changes.
  //   * `event` {Object}
  //     * `newValue` the new value of the key
  //     * `oldValue` the prior value of the key.
  //
  // Returns a {Disposable} with the following keys on which you can call
  // `.dispose()` to unsubscribe.
  onDidChange(...args) {
    let callback, keyPath, scopeDescriptor;
    if (args.length === 1) {
      [callback] = args;
    } else if (args.length === 2) {
      [keyPath, callback] = args;
    } else {
      let options;
      [keyPath, options, callback] = args;
      scopeDescriptor = options.scope;
    }

    if (scopeDescriptor != null) {
      return this.onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback);
    } else {
      return this.onDidChangeKeyPath(keyPath, callback);
    }
  }

  /*
  Section: Managing Settings
  */

  // Essential: Retrieves the setting for the given key.
  //
  // ### Examples
  //
  // You might want to know what themes are enabled, so check `core.themes`
  //
  // ```coffee
  // atom.config.get('core.themes')
  // ```
  //
  // With scope descriptors you can get settings within a specific editor
  // scope. For example, you might want to know `editor.tabLength` for ruby
  // files.
  //
  // ```coffee
  // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  // ```
  //
  // This setting in ruby files might be different than the global tabLength setting
  //
  // ```coffee
  // atom.config.get('editor.tabLength') # => 4
  // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  // ```
  //
  // You can get the language scope descriptor via
  // {TextEditor::getRootScopeDescriptor}. This will get the setting specifically
  // for the editor's language.
  //
  // ```coffee
  // atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2
  // ```
  //
  // Additionally, you can get the setting at the specific cursor position.
  //
  // ```coffee
  // scopeDescriptor = @editor.getLastCursor().getScopeDescriptor()
  // atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2
  // ```
  //
  // * `keyPath` The {String} name of the key to retrieve.
  // * `options` (optional) {Object}
  //   * `sources` (optional) {Array} of {String} source names. If provided, only
  //     values that were associated with these sources during {::set} will be used.
  //   * `excludeSources` (optional) {Array} of {String} source names. If provided,
  //     values that  were associated with these sources during {::set} will not
  //     be used.
  //   * `scope` (optional) {ScopeDescriptor} describing a path from
  //     the root of the syntax tree to a token. Get one by calling
  //     {editor.getLastCursor().getScopeDescriptor()}
  //     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  //     for more information.
  //
  // Returns the value from Atom's default settings, the user's configuration
  // file in the type specified by the configuration schema.
  get(...args) {
    let keyPath, options, scope;
    if (args.length > 1) {
      if (typeof args[0] === 'string' || args[0] == null) {
        [keyPath, options] = args;
        ({ scope } = options);
      }
    } else {
      [keyPath] = args;
    }

    if (scope != null) {
      const value = this.getRawScopedValue(scope, keyPath, options);
      return value != null ? value : this.getRawValue(keyPath, options);
    } else {
      return this.getRawValue(keyPath, options);
    }
  }

  // Extended: Get all of the values for the given key-path, along with their
  // associated scope selector.
  //
  // * `keyPath` The {String} name of the key to retrieve
  // * `options` (optional) {Object} see the `options` argument to {::get}
  //
  // Returns an {Array} of {Object}s with the following keys:
  //  * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated
  //  * `value` The value for the key-path
  getAll(keyPath, options) {
    let globalValue, result, scope;
    if (options != null) {
      ({ scope } = options);
    }

    if (scope != null) {
      let legacyScopeDescriptor;
      const scopeDescriptor = ScopeDescriptor.fromObject(scope);
      result = this.scopedSettingsStore.getAll(
        scopeDescriptor.getScopeChain(),
        keyPath,
        options
      );
      legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor(
        scopeDescriptor
      );
      if (legacyScopeDescriptor) {
        result.push(
          ...Array.from(
            this.scopedSettingsStore.getAll(
              legacyScopeDescriptor.getScopeChain(),
              keyPath,
              options
            ) || []
          )
        );
      }
    } else {
      result = [];
    }

    globalValue = this.getRawValue(keyPath, options);
    if (globalValue) {
      result.push({ scopeSelector: '*', value: globalValue });
    }

    return result;
  }

  // Essential: Sets the value for a configuration setting.
  //
  // This value is stored in Atom's internal configuration file.
  //
  // ### Examples
  //
  // You might want to change the themes programmatically:
  //
  // ```coffee
  // atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax'])
  // ```
  //
  // You can also set scoped settings. For example, you might want change the
  // `editor.tabLength` only for ruby files.
  //
  // ```coffee
  // atom.config.get('editor.tabLength') # => 4
  // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4
  // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
  //
  // # Set ruby to 2
  // atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true
  //
  // # Notice it's only set to 2 in the case of ruby
  // atom.config.get('editor.tabLength') # => 4
  // atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  // atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
  // ```
  //
  // * `keyPath` The {String} name of the key.
  // * `value` The value of the setting. Passing `undefined` will revert the
  //   setting to the default value.
  // * `options` (optional) {Object}
  //   * `scopeSelector` (optional) {String}. eg. '.source.ruby'
  //     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  //     for more information.
  //   * `source` (optional) {String} The name of a file with which the setting
  //     is associated. Defaults to the user's config file.
  //
  // Returns a {Boolean}
  // * `true` if the value was set.
  // * `false` if the value was not able to be coerced to the type specified in the setting's schema.
  set(...args) {
    let [keyPath, value, options = {}] = args;

    if (!this.settingsLoaded) {
      this.pendingOperations.push(() => this.set(keyPath, value, options));
    }

    // We should never use the scoped store to set global settings, since they are kept directly
    // in the config object.
    const scopeSelector =
      options.scopeSelector !== '*' ? options.scopeSelector : undefined;
    let source = options.source;
    const shouldSave = options.save != null ? options.save : true;

    if (source && !scopeSelector && source !== this.projectFile) {
      throw new Error(
        "::set with a 'source' and no 'sourceSelector' is not yet implemented!"
      );
    }

    if (!source) source = this.mainSource;

    if (value !== undefined) {
      try {
        value = this.makeValueConformToSchema(keyPath, value);
      } catch (e) {
        return false;
      }
    }

    if (scopeSelector != null) {
      this.setRawScopedValue(keyPath, value, source, scopeSelector);
    } else {
      this.setRawValue(keyPath, value, { source });
    }

    if (source === this.mainSource && shouldSave && this.settingsLoaded) {
      this.requestSave();
    }
    return true;
  }

  // Essential: Restore the setting at `keyPath` to its default value.
  //
  // * `keyPath` The {String} name of the key.
  // * `options` (optional) {Object}
  //   * `scopeSelector` (optional) {String}. See {::set}
  //   * `source` (optional) {String}. See {::set}
  unset(keyPath, options) {
    if (!this.settingsLoaded) {
      this.pendingOperations.push(() => this.unset(keyPath, options));
    }

    let { scopeSelector, source } = options != null ? options : {};
    if (source == null) {
      source = this.mainSource;
    }

    if (scopeSelector != null) {
      if (keyPath != null) {
        let settings = this.scopedSettingsStore.propertiesForSourceAndSelector(
          source,
          scopeSelector
        );
        if (getValueAtKeyPath(settings, keyPath) != null) {
          this.scopedSettingsStore.removePropertiesForSourceAndSelector(
            source,
            scopeSelector
          );
          setValueAtKeyPath(settings, keyPath, undefined);
          settings = withoutEmptyObjects(settings);
          if (settings != null) {
            this.set(null, settings, {
              scopeSelector,
              source,
              priority: this.priorityForSource(source)
            });
          }

          const configIsReady =
            source === this.mainSource && this.settingsLoaded;
          if (configIsReady) {
            return this.requestSave();
          }
        }
      } else {
        this.scopedSettingsStore.removePropertiesForSourceAndSelector(
          source,
          scopeSelector
        );
        return this.emitChangeEvent();
      }
    } else {
      for (scopeSelector in this.scopedSettingsStore.propertiesForSource(
        source
      )) {
        this.unset(keyPath, { scopeSelector, source });
      }
      if (keyPath != null && source === this.mainSource) {
        return this.set(
          keyPath,
          getValueAtKeyPath(this.defaultSettings, keyPath)
        );
      }
    }
  }

  // Extended: Get an {Array} of all of the `source` {String}s with which
  // settings have been added via {::set}.
  getSources() {
    return _.uniq(
      _.pluck(this.scopedSettingsStore.propertySets, 'source')
    ).sort();
  }

  // Extended: Retrieve the schema for a specific key path. The schema will tell
  // you what type the keyPath expects, and other metadata about the config
  // option.
  //
  // * `keyPath` The {String} name of the key.
  //
  // Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`.
  // Returns `null` when the keyPath has no schema specified, but is accessible
  // from the root schema.
  getSchema(keyPath) {
    const keys = splitKeyPath(keyPath);
    let { schema } = this;
    for (let key of keys) {
      let childSchema;
      if (schema.type === 'object') {
        childSchema =
          schema.properties != null ? schema.properties[key] : undefined;
        if (childSchema == null) {
          if (isPlainObject(schema.additionalProperties)) {
            childSchema = schema.additionalProperties;
          } else if (schema.additionalProperties === false) {
            return null;
          } else {
            return { type: 'any' };
          }
        }
      } else {
        return null;
      }
      schema = childSchema;
    }
    return schema;
  }

  getUserConfigPath() {
    return this.mainSource;
  }

  // Extended: Suppress calls to handler functions registered with {::onDidChange}
  // and {::observe} for the duration of `callback`. After `callback` executes,
  // handlers will be called once if the value for their key-path has changed.
  //
  // * `callback` {Function} to execute while suppressing calls to handlers.
  transact(callback) {
    this.beginTransaction();
    try {
      return callback();
    } finally {
      this.endTransaction();
    }
  }

  getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor) {
    return null;
  }

  /*
  Section: Internal methods used by core
  */

  // Private: Suppress calls to handler functions registered with {::onDidChange}
  // and {::observe} for the duration of the {Promise} returned by `callback`.
  // After the {Promise} is either resolved or rejected, handlers will be called
  // once if the value for their key-path has changed.
  //
  // * `callback` {Function} that returns a {Promise}, which will be executed
  //   while suppressing calls to handlers.
  //
  // Returns a {Promise} that is either resolved or rejected according to the
  // `{Promise}` returned by `callback`. If `callback` throws an error, a
  // rejected {Promise} will be returned instead.
  transactAsync(callback) {
    let endTransaction;
    this.beginTransaction();
    try {
      endTransaction = fn => (...args) => {
        this.endTransaction();
        return fn(...args);
      };
      const result = callback();
      return new Promise((resolve, reject) => {
        return result
          .then(endTransaction(resolve))
          .catch(endTransaction(reject));
      });
    } catch (error) {
      this.endTransaction();
      return Promise.reject(error);
    }
  }

  beginTransaction() {
    this.transactDepth++;
  }

  endTransaction() {
    this.transactDepth--;
    this.emitChangeEvent();
  }

  pushAtKeyPath(keyPath, value) {
    const left = this.get(keyPath);
    const arrayValue = left == null ? [] : left;
    const result = arrayValue.push(value);
    this.set(keyPath, arrayValue);
    return result;
  }

  unshiftAtKeyPath(keyPath, value) {
    const left = this.get(keyPath);
    const arrayValue = left == null ? [] : left;
    const result = arrayValue.unshift(value);
    this.set(keyPath, arrayValue);
    return result;
  }

  removeAtKeyPath(keyPath, value) {
    const left = this.get(keyPath);
    const arrayValue = left == null ? [] : left;
    const result = _.remove(arrayValue, value);
    this.set(keyPath, arrayValue);
    return result;
  }

  setSchema(keyPath, schema) {
    if (!isPlainObject(schema)) {
      throw new Error(
        `Error loading schema for ${keyPath}: schemas can only be objects!`
      );
    }

    if (schema.type == null) {
      throw new Error(
        `Error loading schema for ${keyPath}: schema objects must have a type attribute`
      );
    }

    let rootSchema = this.schema;
    if (keyPath) {
      for (let key of splitKeyPath(keyPath)) {
        rootSchema.type = 'object';
        if (rootSchema.properties == null) {
          rootSchema.properties = {};
        }
        const { properties } = rootSchema;
        if (properties[key] == null) {
          properties[key] = {};
        }
        rootSchema = properties[key];
      }
    }

    Object.assign(rootSchema, schema);
    this.transact(() => {
      this.setDefaults(keyPath, this.extractDefaultsFromSchema(schema));
      this.setScopedDefaultsFromSchema(keyPath, schema);
      this.resetSettingsForSchemaChange();
    });
  }

  save() {
    if (this.saveCallback) {
      let allSettings = { '*': this.settings };
      allSettings = Object.assign(
        allSettings,
        this.scopedSettingsStore.propertiesForSource(this.mainSource)
      );
      allSettings = sortObject(allSettings);
      this.saveCallback(allSettings);
    }
  }

  /*
  Section: Private methods managing global settings
  */

  resetUserSettings(newSettings, options = {}) {
    this._resetSettings(newSettings, options);
  }

  _resetSettings(newSettings, options = {}) {
    const source = options.source;
    newSettings = Object.assign({}, newSettings);
    if (newSettings.global != null) {
      newSettings['*'] = newSettings.global;
      delete newSettings.global;
    }

    if (newSettings['*'] != null) {
      const scopedSettings = newSettings;
      newSettings = newSettings['*'];
      delete scopedSettings['*'];
      this.resetScopedSettings(scopedSettings, { source });
    }

    return this.transact(() => {
      this._clearUnscopedSettingsForSource(source);
      this.settingsLoaded = true;
      for (let key in newSettings) {
        const value = newSettings[key];
        this.set(key, value, { save: false, source });
      }
      if (this.pendingOperations.length) {
        for (let op of this.pendingOperations) {
          op();
        }
        this.pendingOperations = [];
      }
    });
  }

  _clearUnscopedSettingsForSource(source) {
    if (source === this.projectFile) {
      this.projectSettings = {};
    } else {
      this.settings = {};
    }
  }

  resetProjectSettings(newSettings, projectFile) {
    // Sets the scope and source of all project settings to `path`.
    newSettings = Object.assign({}, newSettings);
    const oldProjectFile = this.projectFile;
    this.projectFile = projectFile;
    if (this.projectFile != null) {
      this._resetSettings(newSettings, { source: this.projectFile });
    } else {
      this.scopedSettingsStore.removePropertiesForSource(oldProjectFile);
      this.projectSettings = {};
    }
  }

  clearProjectSettings() {
    this.resetProjectSettings({}, null);
  }

  getRawValue(keyPath, options = {}) {
    let value;
    if (
      !options.excludeSources ||
      !options.excludeSources.includes(this.mainSource)
    ) {
      value = getValueAtKeyPath(this.settings, keyPath);
      if (this.projectFile != null) {
        const projectValue = getValueAtKeyPath(this.projectSettings, keyPath);
        value = projectValue === undefined ? value : projectValue;
      }
    }

    let defaultValue;
    if (!options.sources || options.sources.length === 0) {
      defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath);
    }

    if (value != null) {
      value = this.deepClone(value);
      if (isPlainObject(value) && isPlainObject(defaultValue)) {
        this.deepDefaults(value, defaultValue);
      }
      return value;
    } else {
      return this.deepClone(defaultValue);
    }
  }

  setRawValue(keyPath, value, options = {}) {
    const source = options.source ? options.source : undefined;
    const settingsToChange =
      source === this.projectFile ? 'projectSettings' : 'settings';
    const defaultValue = getValueAtKeyPath(this.defaultSettings, keyPath);

    if (_.isEqual(defaultValue, value)) {
      if (keyPath != null) {
        deleteValueAtKeyPath(this[settingsToChange], keyPath);
      } else {
        this[settingsToChange] = null;
      }
    } else {
      if (keyPath != null) {
        setValueAtKeyPath(this[settingsToChange], keyPath, value);
      } else {
        this[settingsToChange] = value;
      }
    }
    return this.emitChangeEvent();
  }

  observeKeyPath(keyPath, options, callback) {
    callback(this.get(keyPath));
    return this.onDidChangeKeyPath(keyPath, event => callback(event.newValue));
  }

  onDidChangeKeyPath(keyPath, callback) {
    let oldValue = this.get(keyPath);
    return this.emitter.on('did-change', () => {
      const newValue = this.get(keyPath);
      if (!_.isEqual(oldValue, newValue)) {
        const event = { oldValue, newValue };
        oldValue = newValue;
        return callback(event);
      }
    });
  }

  isSubKeyPath(keyPath, subKeyPath) {
    if (keyPath == null || subKeyPath == null) {
      return false;
    }
    const pathSubTokens = splitKeyPath(subKeyPath);
    const pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length);
    return _.isEqual(pathTokens, pathSubTokens);
  }

  setRawDefault(keyPath, value) {
    setValueAtKeyPath(this.defaultSettings, keyPath, value);
    return this.emitChangeEvent();
  }

  setDefaults(keyPath, defaults) {
    if (defaults != null && isPlainObject(defaults)) {
      const keys = splitKeyPath(keyPath);
      this.transact(() => {
        const result = [];
        for (let key in defaults) {
          const childValue = defaults[key];
          if (!defaults.hasOwnProperty(key)) {
            continue;
          }
          result.push(
            this.setDefaults(keys.concat([key]).join('.'), childValue)
          );
        }
        return result;
      });
    } else {
      try {
        defaults = this.makeValueConformToSchema(keyPath, defaults);
        this.setRawDefault(keyPath, defaults);
      } catch (e) {
        console.warn(
          `'${keyPath}' could not set the default. Attempted default: ${JSON.stringify(
            defaults
          )}; Schema: ${JSON.stringify(this.getSchema(keyPath))}`
        );
      }
    }
  }

  deepClone(object) {
    if (object instanceof Color) {
      return object.clone();
    } else if (Array.isArray(object)) {
      return object.map(value => this.deepClone(value));
    } else if (isPlainObject(object)) {
      return _.mapObject(object, (key, value) => [key, this.deepClone(value)]);
    } else {
      return object;
    }
  }

  deepDefaults(target) {
    let result = target;
    let i = 0;
    while (++i < arguments.length) {
      const object = arguments[i];
      if (isPlainObject(result) && isPlainObject(object)) {
        for (let key of Object.keys(object)) {
          result[key] = this.deepDefaults(result[key], object[key]);
        }
      } else {
        if (result == null) {
          result = this.deepClone(object);
        }
      }
    }
    return result;
  }

  // `schema` will look something like this
  //
  // ```coffee
  // type: 'string'
  // default: 'ok'
  // scopes:
  //   '.source.js':
  //     default: 'omg'
  // ```
  setScopedDefaultsFromSchema(keyPath, schema) {
    if (schema.scopes != null && isPlainObject(schema.scopes)) {
      const scopedDefaults = {};
      for (let scope in schema.scopes) {
        const scopeSchema = schema.scopes[scope];
        if (!scopeSchema.hasOwnProperty('default')) {
          continue;
        }
        scopedDefaults[scope] = {};
        setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default);
      }
      this.scopedSettingsStore.addProperties('schema-default', scopedDefaults);
    }

    if (
      schema.type === 'object' &&
      schema.properties != null &&
      isPlainObject(schema.properties)
    ) {
      const keys = splitKeyPath(keyPath);
      for (let key in schema.properties) {
        const childValue = schema.properties[key];
        if (!schema.properties.hasOwnProperty(key)) {
          continue;
        }
        this.setScopedDefaultsFromSchema(
          keys.concat([key]).join('.'),
          childValue
        );
      }
    }
  }

  extractDefaultsFromSchema(schema) {
    if (schema.default != null) {
      return schema.default;
    } else if (
      schema.type === 'object' &&
      schema.properties != null &&
      isPlainObject(schema.properties)
    ) {
      const defaults = {};
      const properties = schema.properties || {};
      for (let key in properties) {
        const value = properties[key];
        defaults[key] = this.extractDefaultsFromSchema(value);
      }
      return defaults;
    }
  }

  makeValueConformToSchema(keyPath, value, options) {
    if (options != null ? options.suppressException : undefined) {
      try {
        return this.makeValueConformToSchema(keyPath, value);
      } catch (e) {
        return undefined;
      }
    } else {
      let schema;
      if ((schema = this.getSchema(keyPath)) == null) {
        if (schema === false) {
          throw new Error(`Illegal key path ${keyPath}`);
        }
      }
      return this.constructor.executeSchemaEnforcers(keyPath, value, schema);
    }
  }

  // When the schema is changed / added, there may be values set in the config
  // that do not conform to the schema. This will reset make them conform.
  resetSettingsForSchemaChange(source) {
    if (source == null) {
      source = this.mainSource;
    }
    return this.transact(() => {
      this.settings = this.makeValueConformToSchema(null, this.settings, {
        suppressException: true
      });
      const selectorsAndSettings = this.scopedSettingsStore.propertiesForSource(
        source
      );
      this.scopedSettingsStore.removePropertiesForSource(source);
      for (let scopeSelector in selectorsAndSettings) {
        let settings = selectorsAndSettings[scopeSelector];
        settings = this.makeValueConformToSchema(null, settings, {
          suppressException: true
        });
        this.setRawScopedValue(null, settings, source, scopeSelector);
      }
    });
  }

  /*
  Section: Private Scoped Settings
  */

  priorityForSource(source) {
    switch (source) {
      case this.mainSource:
        return 1000;
      case this.projectFile:
        return 2000;
      default:
        return 0;
    }
  }

  emitChangeEvent() {
    if (this.transactDepth <= 0) {
      return this.emitter.emit('did-change');
    }
  }

  resetScopedSettings(newScopedSettings, options = {}) {
    const source = options.source == null ? this.mainSource : options.source;
    const priority = this.priorityForSource(source);
    this.scopedSettingsStore.removePropertiesForSource(source);

    for (let scopeSelector in newScopedSettings) {
      let settings = newScopedSettings[scopeSelector];
      settings = this.makeValueConformToSchema(null, settings, {
        suppressException: true
      });
      const validatedSettings = {};
      validatedSettings[scopeSelector] = withoutEmptyObjects(settings);
      if (validatedSettings[scopeSelector] != null) {
        this.scopedSettingsStore.addProperties(source, validatedSettings, {
          priority
        });
      }
    }

    return this.emitChangeEvent();
  }

  setRawScopedValue(keyPath, value, source, selector, options) {
    if (keyPath != null) {
      const newValue = {};
      setValueAtKeyPath(newValue, keyPath, value);
      value = newValue;
    }

    const settingsBySelector = {};
    settingsBySelector[selector] = value;
    this.scopedSettingsStore.addProperties(source, settingsBySelector, {
      priority: this.priorityForSource(source)
    });
    return this.emitChangeEvent();
  }

  getRawScopedValue(scopeDescriptor, keyPath, options) {
    scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor);
    const result = this.scopedSettingsStore.getPropertyValue(
      scopeDescriptor.getScopeChain(),
      keyPath,
      options
    );

    const legacyScopeDescriptor = this.getLegacyScopeDescriptorForNewScopeDescriptor(
      scopeDescriptor
    );
    if (result != null) {
      return result;
    } else if (legacyScopeDescriptor) {
      return this.scopedSettingsStore.getPropertyValue(
        legacyScopeDescriptor.getScopeChain(),
        keyPath,
        options
      );
    }
  }

  observeScopedKeyPath(scope, keyPath, callback) {
    callback(this.get(keyPath, { scope }));
    return this.onDidChangeScopedKeyPath(scope, keyPath, event =>
      callback(event.newValue)
    );
  }

  onDidChangeScopedKeyPath(scope, keyPath, callback) {
    let oldValue = this.get(keyPath, { scope });
    return this.emitter.on('did-change', () => {
      const newValue = this.get(keyPath, { scope });
      if (!_.isEqual(oldValue, newValue)) {
        const event = { oldValue, newValue };
        oldValue = newValue;
        callback(event);
      }
    });
  }
}

// Base schema enforcers. These will coerce raw input into the specified type,
// and will throw an error when the value cannot be coerced. Throwing the error
// will indicate that the value should not be set.
//
// Enforcers are run from most specific to least. For a schema with type
// `integer`, all the enforcers for the `integer` type will be run first, in
// order of specification. Then the `*` enforcers will be run, in order of
// specification.
Config.addSchemaEnforcers({
  any: {
    coerce(keyPath, value, schema) {
      return value;
    }
  },

  integer: {
    coerce(keyPath, value, schema) {
      value = parseInt(value);
      if (isNaN(value) || !isFinite(value)) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} cannot be coerced into an int`
        );
      }
      return value;
    }
  },

  number: {
    coerce(keyPath, value, schema) {
      value = parseFloat(value);
      if (isNaN(value) || !isFinite(value)) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} cannot be coerced into a number`
        );
      }
      return value;
    }
  },

  boolean: {
    coerce(keyPath, value, schema) {
      switch (typeof value) {
        case 'string':
          if (value.toLowerCase() === 'true') {
            return true;
          } else if (value.toLowerCase() === 'false') {
            return false;
          } else {
            throw new Error(
              `Validation failed at ${keyPath}, ${JSON.stringify(
                value
              )} must be a boolean or the string 'true' or 'false'`
            );
          }
        case 'boolean':
          return value;
        default:
          throw new Error(
            `Validation failed at ${keyPath}, ${JSON.stringify(
              value
            )} must be a boolean or the string 'true' or 'false'`
          );
      }
    }
  },

  string: {
    validate(keyPath, value, schema) {
      if (typeof value !== 'string') {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} must be a string`
        );
      }
      return value;
    },

    validateMaximumLength(keyPath, value, schema) {
      if (
        typeof schema.maximumLength === 'number' &&
        value.length > schema.maximumLength
      ) {
        return value.slice(0, schema.maximumLength);
      } else {
        return value;
      }
    }
  },

  null: {
    // null sort of isnt supported. It will just unset in this case
    coerce(keyPath, value, schema) {
      if (![undefined, null].includes(value)) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} must be null`
        );
      }
      return value;
    }
  },

  object: {
    coerce(keyPath, value, schema) {
      if (!isPlainObject(value)) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} must be an object`
        );
      }
      if (schema.properties == null) {
        return value;
      }

      let defaultChildSchema = null;
      let allowsAdditionalProperties = true;
      if (isPlainObject(schema.additionalProperties)) {
        defaultChildSchema = schema.additionalProperties;
      }
      if (schema.additionalProperties === false) {
        allowsAdditionalProperties = false;
      }

      const newValue = {};
      for (let prop in value) {
        const propValue = value[prop];
        const childSchema =
          schema.properties[prop] != null
            ? schema.properties[prop]
            : defaultChildSchema;
        if (childSchema != null) {
          try {
            newValue[prop] = this.executeSchemaEnforcers(
              pushKeyPath(keyPath, prop),
              propValue,
              childSchema
            );
          } catch (error) {
            console.warn(`Error setting item in object: ${error.message}`);
          }
        } else if (allowsAdditionalProperties) {
          // Just pass through un-schema'd values
          newValue[prop] = propValue;
        } else {
          console.warn(`Illegal object key: ${keyPath}.${prop}`);
        }
      }

      return newValue;
    }
  },

  array: {
    coerce(keyPath, value, schema) {
      if (!Array.isArray(value)) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} must be an array`
        );
      }
      const itemSchema = schema.items;
      if (itemSchema != null) {
        const newValue = [];
        for (let item of value) {
          try {
            newValue.push(
              this.executeSchemaEnforcers(keyPath, item, itemSchema)
            );
          } catch (error) {
            console.warn(`Error setting item in array: ${error.message}`);
          }
        }
        return newValue;
      } else {
        return value;
      }
    }
  },

  color: {
    coerce(keyPath, value, schema) {
      const color = Color.parse(value);
      if (color == null) {
        throw new Error(
          `Validation failed at ${keyPath}, ${JSON.stringify(
            value
          )} cannot be coerced into a color`
        );
      }
      return color;
    }
  },

  '*': {
    coerceMinimumAndMaximum(keyPath, value, schema) {
      if (typeof value !== 'number') {
        return value;
      }
      if (schema.minimum != null && typeof schema.minimum === 'number') {
        value = Math.max(value, schema.minimum);
      }
      if (schema.maximum != null && typeof schema.maximum === 'number') {
        value = Math.min(value, schema.maximum);
      }
      return value;
    },

    validateEnum(keyPath, value, schema) {
      let possibleValues = schema.enum;

      if (Array.isArray(possibleValues)) {
        possibleValues = possibleValues.map(value => {
          if (value.hasOwnProperty('value')) {
            return value.value;
          } else {
            return value;
          }
        });
      }

      if (
        possibleValues == null ||
        !Array.isArray(possibleValues) ||
        !possibleValues.length
      ) {
        return value;
      }

      for (let possibleValue of possibleValues) {
        // Using `isEqual` for possibility of placing enums on array and object schemas
        if (_.isEqual(possibleValue, value)) {
          return value;
        }
      }

      throw new Error(
        `Validation failed at ${keyPath}, ${JSON.stringify(
          value
        )} is not one of ${JSON.stringify(possibleValues)}`
      );
    }
  }
});

let isPlainObject = value =>
  _.isObject(value) &&
  !Array.isArray(value) &&
  !_.isFunction(value) &&
  !_.isString(value) &&
  !(value instanceof Color);

let sortObject = value => {
  if (!isPlainObject(value)) {
    return value;
  }
  const result = {};
  for (let key of Object.keys(value).sort()) {
    result[key] = sortObject(value[key]);
  }
  return result;
};

const withoutEmptyObjects = object => {
  let resultObject;
  if (isPlainObject(object)) {
    for (let key in object) {
      const value = object[key];
      const newValue = withoutEmptyObjects(value);
      if (newValue != null) {
        if (resultObject == null) {
          resultObject = {};
        }
        resultObject[key] = newValue;
      }
    }
  } else {
    resultObject = object;
  }
  return resultObject;
};

module.exports = Config;