Files
atom/src/config.coffee

474 lines
16 KiB
CoffeeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
_ = require 'underscore-plus'
fs = require 'fs-plus'
EmitterMixin = require('emissary').Emitter
{Emitter} = require 'event-kit'
CSON = require 'season'
path = require 'path'
async = require 'async'
pathWatcher = require 'pathwatcher'
{deprecate} = require 'grim'
# Essential: Used to access all of Atom's configuration details.
#
# An instance of this class is always available as the `atom.config` global.
#
# ## Best practices
#
# * Create your own root keypath using your package's name.
# * Don't depend on (or write to) configuration keys outside of your keypath.
#
# ## Examples
#
# ```coffee
# atom.config.set('my-package.key', 'value')
# atom.config.observe 'my-package.key', ->
# console.log 'My configuration changed:', atom.config.get('my-package.key')
# ```
module.exports =
class Config
EmitterMixin.includeInto(this)
@schemaValidators = {}
@addSchemaValidator: (typeName, validatorFunction) ->
@schemaValidators[typeName] ?= []
@schemaValidators[typeName].push(validatorFunction)
@addSchemaValidators: (filters) ->
for typeName, functions of filters
for name, validatorFunction of functions
@addSchemaValidator(typeName, validatorFunction)
@executeSchemaValidators: (keyPath, value, schema) ->
error = null
types = schema.type
types = [types] unless Array.isArray(types)
for type in types
try
if filterFunctions = @schemaValidators[type]
filterFunctions = filterFunctions.concat(@schemaValidators['*'])
for filter in filterFunctions
value = filter.call(this, keyPath, value, schema)
error = null
break
catch e
error = e
throw error if error?
value
# Created during initialization, available as `atom.config`
constructor: ({@configDirPath, @resourcePath}={}) ->
@emitter = new Emitter
@schema =
type: 'object'
properties: {}
@defaultSettings = {}
@settings = {}
@configFileHasErrors = false
@configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson'])
@configFilePath ?= path.join(@configDirPath, 'config.cson')
###
Section: Config Subscription
###
# Essential: Add a listener for changes to a given key path.
#
# * `keyPath` The {String} name of the key to observe
# * `callback` The {Function} to call when the value of the key changes.
# The first argument will be the new value of the key and the
#   second argument will be an {Object} with a `previous` property
# that is the prior value of the key.
#
# Returns a {Disposable} with the following keys on which you can call
# `.dispose()` to unsubscribe.
onDidChange: (keyPath, callback) ->
value = @get(keyPath)
previousValue = _.clone(value)
updateCallback = =>
value = @get(keyPath)
unless _.isEqual(value, previousValue)
previous = previousValue
previousValue = _.clone(value)
callback(value, {previous})
@emitter.on 'did-change', updateCallback
# 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.
#
# * `keyPath` The {String} name of the key to observe
# * `callback` The {Function} to call when the value of the key changes.
# The first argument will be the new value of the key and the
#   second argument will be an {Object} with a `previous` property
# that is the prior value of the key.
#
# Returns a {Disposable} with the following keys on which you can call
# `.dispose()` to unsubscribe.
observe: (keyPath, options={}, callback) ->
if _.isFunction(options)
callback = options
options = {}
else
message = ""
message = "`callNow` was set to false. Use ::onDidChange instead." if options.callNow == false
deprecate "Config::observe no longer supports options. #{message}"
callback(_.clone(@get(keyPath))) unless options.callNow == false
@onDidChange(keyPath, callback)
###
Section: get / set
###
# Essential: Retrieves the setting for the given key.
#
# * `keyPath` The {String} name of the key to retrieve.
#
# Returns the value from Atom's default settings, the user's configuration
# file, or `null` if the key doesn't exist in either.
get: (keyPath) ->
value = _.valueForKeyPath(@settings, keyPath)
defaultValue = _.valueForKeyPath(@defaultSettings, keyPath)
if value?
value = _.deepClone(value)
valueIsObject = _.isObject(value) and not _.isArray(value)
defaultValueIsObject = _.isObject(defaultValue) and not _.isArray(defaultValue)
if valueIsObject and defaultValueIsObject
_.defaults(value, defaultValue)
else
value = _.deepClone(defaultValue)
value
# Essential: Sets the value for a configuration setting.
#
# This value is stored in Atom's internal configuration file.
#
# * `keyPath` The {String} name of the key.
# * `value` The value of the setting.
#
# Returns a {Boolean} true if the value was set.
set: (keyPath, value) ->
try
value = @scrubValue(keyPath, value)
catch e
return false
if @get(keyPath) isnt value
defaultValue = _.valueForKeyPath(@defaultSettings, keyPath)
value = undefined if _.isEqual(defaultValue, value)
_.setValueForKeyPath(@settings, keyPath, value)
@update()
true
# Extended: Get the {String} path to the config file being used.
getUserConfigPath: ->
@configFilePath
# Extended: Returns a new {Object} containing all of settings and defaults.
getSettings: ->
_.deepExtend(@settings, @defaultSettings)
# Extended: Restore the key path to its default value.
#
# * `keyPath` The {String} name of the key.
#
# Returns the new value.
restoreDefault: (keyPath) ->
@set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath))
# Extended: Get the default value of the key path.
#
# * `keyPath` The {String} name of the key.
#
# Returns the default value.
getDefault: (keyPath) ->
defaultValue = _.valueForKeyPath(@defaultSettings, keyPath)
_.deepClone(defaultValue)
# Extended: Is the key path value its default value?
#
# * `keyPath` The {String} name of the key.
#
# Returns a {Boolean}, `true` if the current value is the default, `false`
# otherwise.
isDefault: (keyPath) ->
not _.valueForKeyPath(@settings, keyPath)?
getSchema: (keyPath) ->
keys = keyPath.split('.')
schema = @schema
for key in keys
break unless schema?
schema = schema.properties[key]
schema
###
Section: Deprecated
###
getInt: (keyPath) ->
deprecate '''Config::getInt is no longer necessary. Use ::get instead.
Make sure the config option you are accessing has specified an `integer`
schema. See the configuration section of
https://atom.io/docs/latest/creating-a-package for more info.'''
parseInt(@get(keyPath))
getPositiveInt: (keyPath, defaultValue=0) ->
deprecate '''Config::getPositiveInt is no longer necessary. Use ::get instead.
Make sure the config option you are accessing has specified an `integer`
schema with `minimum: 1`. See the configuration section of
https://atom.io/docs/latest/creating-a-package for more info.'''
Math.max(@getInt(keyPath), 0) or defaultValue
toggle: (keyPath) ->
deprecate 'Config::toggle is no longer supported. Please remove from your code.'
@set(keyPath, !@get(keyPath))
unobserve: (keyPath) ->
deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.'
pushAtKeyPath: (keyPath, value) ->
deprecate 'Please remove from your code. Config::pushAtKeyPath is going away. Please push the value onto the array, and call Config::set'
arrayValue = @get(keyPath) ? []
result = arrayValue.push(value)
@set(keyPath, arrayValue)
result
unshiftAtKeyPath: (keyPath, value) ->
deprecate 'Please remove from your code. Config::unshiftAtKeyPath is going away. Please unshift the value onto the array, and call Config::set'
arrayValue = @get(keyPath) ? []
result = arrayValue.unshift(value)
@set(keyPath, arrayValue)
result
removeAtKeyPath: (keyPath, value) ->
deprecate 'Please remove from your code. Config::removeAtKeyPath is going away. Please remove the value from the array, and call Config::set'
arrayValue = @get(keyPath) ? []
result = _.remove(arrayValue, value)
@set(keyPath, arrayValue)
result
###
Section: Private
###
initializeConfigDirectory: (done) ->
return if fs.existsSync(@configDirPath)
fs.makeTreeSync(@configDirPath)
queue = async.queue ({sourcePath, destinationPath}, callback) ->
fs.copy(sourcePath, destinationPath, callback)
queue.drain = done
templateConfigDirPath = fs.resolve(@resourcePath, 'dot-atom')
onConfigDirFile = (sourcePath) =>
relativePath = sourcePath.substring(templateConfigDirPath.length + 1)
destinationPath = path.join(@configDirPath, relativePath)
queue.push({sourcePath, destinationPath})
fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true)
load: ->
@initializeConfigDirectory()
@loadUserConfig()
@observeUserConfig()
loadUserConfig: ->
unless fs.existsSync(@configFilePath)
fs.makeTreeSync(path.dirname(@configFilePath))
CSON.writeFileSync(@configFilePath, {})
try
userConfig = CSON.readFileSync(@configFilePath)
@setAllRecursive(userConfig)
@configFileHasErrors = false
@emit 'updated'
@emitter.emit 'did-change'
catch error
@configFileHasErrors = true
console.error "Failed to load user config '#{@configFilePath}'", error.message
console.error error.stack
observeUserConfig: ->
try
@watchSubscription ?= pathWatcher.watch @configFilePath, (eventType) =>
@loadUserConfig() if eventType is 'change' and @watchSubscription?
catch error
console.error "Failed to watch user config '#{@configFilePath}'", error.message
console.error error.stack
unobserveUserConfig: ->
@watchSubscription?.close()
@watchSubscription = null
update: ->
return if @configFileHasErrors
@save()
@emit 'updated'
@emitter.emit 'did-change'
save: ->
CSON.writeFileSync(@configFilePath, @settings)
setAllRecursive: (value) ->
@setRecursive(key, childValue) for key, childValue of value
return
setRecursive: (keyPath, value) ->
if value? and isPlainObject(value)
keys = keyPath.split('.')
for key, childValue of value
continue unless value.hasOwnProperty(key)
@setRecursive(keys.concat([key]).join('.'), childValue)
else
try
value = @scrubValue(keyPath, value)
defaultValue = _.valueForKeyPath(@defaultSettings, keyPath)
value = undefined if _.isEqual(defaultValue, value)
_.setValueForKeyPath(@settings, keyPath, value)
catch e
console.warn("'#{keyPath}' could not be set. Attempted value: #{JSON.stringify(value)}; Schema: #{JSON.stringify(@getSchema(keyPath))}")
return
setDefaults: (keyPath, defaults) ->
unless isPlainObject(defaults)
return _.setValueForKeyPath(@defaultSettings, keyPath, defaults)
hash = @defaultSettings
if keyPath
for key in keyPath.split('.')
hash[key] ?= {}
hash = hash[key]
_.extend hash, defaults
@emit 'updated'
@emitter.emit 'did-change'
setSchema: (keyPath, schema) ->
unless typeof schema is "object"
throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!")
unless typeof schema.type?
throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute")
rootSchema = @schema
if keyPath
for key in keyPath.split('.')
rootSchema.type = 'object'
rootSchema.properties ?= {}
properties = rootSchema.properties
properties[key] ?= {}
rootSchema = properties[key]
_.extend rootSchema, schema
@setDefaults(keyPath, @extractDefaultsFromSchema(schema))
extractDefaultsFromSchema: (schema) ->
if schema.default?
schema.default
else if schema.type is 'object' and schema.properties? and typeof schema.properties is "object"
defaults = {}
properties = schema.properties or {}
defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties
defaults
scrubValue: (keyPath, value) ->
value = @constructor.executeSchemaValidators(keyPath, value, schema) if schema = @getSchema(keyPath)
value
# Base schema validators. 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.
#
# Validators are run from most specific to least. For a schema with type
# `integer`, all the validators for the `integer` type will be run first, in
# order of specification. Then the `*` validators will be run, in order of
# specification.
Config.addSchemaValidators
'integer':
coercion: (keyPath, value, schema) ->
value = parseInt(value)
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value)
value
'number':
coercion: (keyPath, value, schema) ->
value = parseFloat(value)
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value)
value
'boolean':
coercion: (keyPath, value, schema) ->
switch typeof value
when 'string'
value.toLowerCase() in ['true', 't']
else
!!value
'string':
coercion: (keyPath, value, schema) ->
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} must be a string") if typeof value isnt 'string'
value
'null':
# null sort of isnt supported. It will just unset in this case
coercion: (keyPath, value, schema) ->
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} must be null") unless value == null
value
'object':
coercion: (keyPath, value, schema) ->
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value)
return value unless schema.properties?
newValue = {}
for prop, childSchema of schema.properties
continue unless value.hasOwnProperty(prop)
try
newValue[prop] = @executeSchemaValidators("#{keyPath}.#{prop}", value[prop], childSchema)
catch error
console.warn "Error setting item in object: #{error.message}"
newValue
'array':
coercion: (keyPath, value, schema) ->
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value)
itemSchema = schema.items
if itemSchema?
newValue = []
for item in value
try
newValue.push @executeSchemaValidators(keyPath, item, itemSchema)
catch error
console.warn "Error setting item in array: #{error.message}"
newValue
else
value
'*':
minimumAndMaximumCoercion: (keyPath, value, schema) ->
return value unless typeof value is 'number'
if schema.minimum? and typeof schema.minimum is 'number'
value = Math.max(value, schema.minimum)
if schema.maximum? and typeof schema.maximum is 'number'
value = Math.min(value, schema.maximum)
value
enumValidation: (keyPath, value, schema) ->
possibleValues = schema.enum
return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length
for possibleValue in possibleValues
# Using `isEqual` for possibility of placing enums on array and object schemas
return value if _.isEqual(possibleValue, value)
throw new Error("Cannot set #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}")
isPlainObject = (value) ->
_.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value)