diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index 0c9251054..c42add20c 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -862,3 +862,106 @@ describe "Config", -> expect(atom.config.set('foo.bar.arr', ['two', 'three'])).toBe true expect(atom.config.get('foo.bar.arr')).toEqual ['two', 'three'] + + describe "scoped settings", -> + describe ".get(scopeDescriptor, keyPath)", -> + it "returns the property with the most specific scope selector", -> + atom.config.addScopedSettings(".source.coffee .string.quoted.double.coffee", foo: bar: baz: 42) + atom.config.addScopedSettings(".source .string.quoted.double", foo: bar: baz: 22) + atom.config.addScopedSettings(".source", foo: bar: baz: 11) + + expect(atom.config.get([".source.coffee", ".string.quoted.double.coffee"], "foo.bar.baz")).toBe 42 + expect(atom.config.get([".source.js", ".string.quoted.double.js"], "foo.bar.baz")).toBe 22 + expect(atom.config.get([".source.js", ".variable.assignment.js"], "foo.bar.baz")).toBe 11 + expect(atom.config.get([".text"], "foo.bar.baz")).toBeUndefined() + + it "favors the most recently added properties in the event of a specificity tie", -> + atom.config.addScopedSettings(".source.coffee .string.quoted.single", foo: bar: baz: 42) + atom.config.addScopedSettings(".source.coffee .string.quoted.double", foo: bar: baz: 22) + + expect(atom.config.get([".source.coffee", ".string.quoted.single"], "foo.bar.baz")).toBe 42 + expect(atom.config.get([".source.coffee", ".string.quoted.single.double"], "foo.bar.baz")).toBe 22 + + describe 'when there are global defaults', -> + it 'falls back to the global when there is no scoped property specified', -> + atom.config.setDefaults("foo", hasDefault: 'ok') + expect(atom.config.get([".source.coffee", ".string.quoted.single"], "foo.hasDefault")).toBe 'ok' + + describe ".set(scope, keyPath, value)", -> + it "sets the value and overrides the others", -> + atom.config.addScopedSettings(".source.coffee .string.quoted.double.coffee", foo: bar: baz: 42) + atom.config.addScopedSettings(".source .string.quoted.double", foo: bar: baz: 22) + atom.config.addScopedSettings(".source", foo: bar: baz: 11) + + expect(atom.config.get([".source.coffee", ".string.quoted.double.coffee"], "foo.bar.baz")).toBe 42 + + expect(atom.config.set(".source.coffee .string.quoted.double.coffee", "foo.bar.baz", 100)).toBe true + expect(atom.config.get([".source.coffee", ".string.quoted.double.coffee"], "foo.bar.baz")).toBe 100 + + describe ".removeScopedSettingsForName(name)", -> + it "allows properties to be removed by name", -> + disposable1 = atom.config.addScopedSettings("a", ".source.coffee .string.quoted.double.coffee", foo: bar: baz: 42) + disposable2 = atom.config.addScopedSettings("b", ".source .string.quoted.double", foo: bar: baz: 22) + + disposable2.dispose() + expect(atom.config.get([".source.js", ".string.quoted.double.js"], "foo.bar.baz")).toBeUndefined() + expect(atom.config.get([".source.coffee", ".string.quoted.double.coffee"], "foo.bar.baz")).toBe 42 + + describe ".observe(scopeDescriptor, keyPath)", -> + it 'calls the supplied callback when the value at the descriptor/keypath changes', -> + atom.config.observe [".source.coffee", ".string.quoted.double.coffee"], "foo.bar.baz", changeSpy = jasmine.createSpy() + expect(changeSpy).toHaveBeenCalledWith(undefined) + changeSpy.reset() + + atom.config.set("foo.bar.baz", 12) + expect(changeSpy).toHaveBeenCalledWith(12) + changeSpy.reset() + + disposable1 = atom.config.addScopedSettings(".source .string.quoted.double", foo: bar: baz: 22) + expect(changeSpy).toHaveBeenCalledWith(22) + changeSpy.reset() + + disposable2 = atom.config.addScopedSettings("a", ".source.coffee .string.quoted.double.coffee", foo: bar: baz: 42) + expect(changeSpy).toHaveBeenCalledWith(42) + changeSpy.reset() + + disposable2.dispose() + expect(changeSpy).toHaveBeenCalledWith(22) + changeSpy.reset() + + disposable1.dispose() + expect(changeSpy).toHaveBeenCalledWith(12) + changeSpy.reset() + + atom.config.set("foo.bar.baz", undefined) + expect(changeSpy).toHaveBeenCalledWith(undefined) + changeSpy.reset() + + describe ".onDidChange(scopeDescriptor, keyPath)", -> + it 'calls the supplied callback when the value at the descriptor/keypath changes', -> + keyPath = "foo.bar.baz" + atom.config.onDidChange [".source.coffee", ".string.quoted.double.coffee"], keyPath, changeSpy = jasmine.createSpy() + + atom.config.set("foo.bar.baz", 12) + expect(changeSpy).toHaveBeenCalledWith({oldValue: undefined, newValue: 12, keyPath}) + changeSpy.reset() + + disposable1 = atom.config.addScopedSettings(".source .string.quoted.double", foo: bar: baz: 22) + expect(changeSpy).toHaveBeenCalledWith({oldValue: 12, newValue: 22, keyPath}) + changeSpy.reset() + + disposable2 = atom.config.addScopedSettings("a", ".source.coffee .string.quoted.double.coffee", foo: bar: baz: 42) + expect(changeSpy).toHaveBeenCalledWith({oldValue: 22, newValue: 42, keyPath}) + changeSpy.reset() + + disposable2.dispose() + expect(changeSpy).toHaveBeenCalledWith({oldValue: 42, newValue: 22, keyPath}) + changeSpy.reset() + + disposable1.dispose() + expect(changeSpy).toHaveBeenCalledWith({oldValue: 22, newValue: 12, keyPath}) + changeSpy.reset() + + atom.config.set("foo.bar.baz", undefined) + expect(changeSpy).toHaveBeenCalledWith({oldValue: 12, newValue: undefined, keyPath}) + changeSpy.reset() diff --git a/spec/package-manager-spec.coffee b/spec/package-manager-spec.coffee index 697c5b732..bb2f70c27 100644 --- a/spec/package-manager-spec.coffee +++ b/spec/package-manager-spec.coffee @@ -313,7 +313,7 @@ describe "PackageManager", -> atom.packages.activatePackage("package-with-scoped-properties") runs -> - expect(atom.syntax.getProperty ['.source.omg'], 'editor.increaseIndentPattern').toBe '^a' + expect(atom.config.get ['.source.omg'], 'editor.increaseIndentPattern').toBe '^a' describe "converted textmate packages", -> it "loads the package's grammars", -> @@ -326,15 +326,18 @@ describe "PackageManager", -> expect(atom.syntax.selectGrammar("file.rb").name).toBe "Ruby" it "loads the translated scoped properties", -> - expect(atom.syntax.getProperty(['.source.ruby'], 'editor.commentStart')).toBeUndefined() + expect(atom.config.get(['.source.ruby'], 'editor.commentStart')).toBeUndefined() waitsForPromise -> atom.packages.activatePackage('language-ruby') runs -> - expect(atom.syntax.getProperty(['.source.ruby'], 'editor.commentStart')).toBe '# ' + expect(atom.config.get(['.source.ruby'], 'editor.commentStart')).toBe '# ' describe "::deactivatePackage(id)", -> + afterEach -> + atom.packages.unloadPackages() + describe "atom packages", -> it "calls `deactivate` on the package's main module if activate was successful", -> pack = null @@ -436,9 +439,9 @@ describe "PackageManager", -> atom.packages.activatePackage("package-with-scoped-properties") runs -> - expect(atom.syntax.getProperty ['.source.omg'], 'editor.increaseIndentPattern').toBe '^a' + expect(atom.config.get ['.source.omg'], 'editor.increaseIndentPattern').toBe '^a' atom.packages.deactivatePackage("package-with-scoped-properties") - expect(atom.syntax.getProperty ['.source.omg'], 'editor.increaseIndentPattern').toBeUndefined() + expect(atom.config.get ['.source.omg'], 'editor.increaseIndentPattern').toBeUndefined() describe "textmate packages", -> it "removes the package's grammars", -> @@ -458,7 +461,7 @@ describe "PackageManager", -> runs -> atom.packages.deactivatePackage('language-ruby') - expect(atom.syntax.getProperty(['.source.ruby'], 'editor.commentStart')).toBeUndefined() + expect(atom.config.get(['.source.ruby'], 'editor.commentStart')).toBeUndefined() describe "::activate()", -> packageActivator = null diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 68c382d47..7f99d9c2b 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -82,7 +82,6 @@ beforeEach -> spyOn(atom, 'saveSync') atom.syntax.clearGrammarOverrides() - atom.syntax.clearProperties() spy = spyOn(atom.packages, 'resolvePackagePath').andCallFake (packageName) -> if specPackageName and packageName is specPackageName diff --git a/src/config-schema.coffee b/src/config-schema.coffee index 4f7779dda..a400888be 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -1,7 +1,8 @@ path = require 'path' fs = require 'fs-plus' -# This is loaded by atom.coffee +# This is loaded by atom.coffee. See https://atom.io/docs/api/latest/Config for +# more information about config schemas. module.exports = core: type: 'object' @@ -38,6 +39,19 @@ module.exports = editor: type: 'object' properties: + # These settings are used in scoped fashion only. No defaults. + commentStart: + type: ['string', 'null'] + commentEnd: + type: ['string', 'null'] + increaseIndentPattern: + type: ['string', 'null'] + decreaseIndentPattern: + type: ['string', 'null'] + foldEndPattern: + type: ['string', 'null'] + + # These can be used as globals or scoped, thus defaults. fontFamily: type: 'string' default: '' diff --git a/src/config.coffee b/src/config.coffee index 19acf887e..bb45a09ec 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -1,13 +1,15 @@ _ = require 'underscore-plus' fs = require 'fs-plus' EmitterMixin = require('emissary').Emitter -{Emitter} = require 'event-kit' +{Disposable, Emitter} = require 'event-kit' CSON = require 'season' path = require 'path' async = require 'async' pathWatcher = require 'pathwatcher' {deprecate} = require 'grim' +ScopedPropertyStore = require 'scoped-property-store' + # Essential: Used to access all of Atom's configuration details. # # An instance of this class is always available as the `atom.config` global. @@ -219,7 +221,7 @@ pathWatcher = require 'pathwatcher' # # All types support an `enum` key. The enum key lets you specify all values # that the config setting can possibly be. `enum` _must_ be an array of values -# of your specified type. +# of your specified type. Schema: # # ```coffee # config: @@ -229,6 +231,8 @@ pathWatcher = require 'pathwatcher' # enum: [2, 4, 6, 8] # ``` # +# Usage: +# # ```coffee # atom.config.set('my-package.someSetting', '2') # atom.config.get('my-package.someSetting') # -> 2 @@ -307,6 +311,7 @@ class Config properties: {} @defaultSettings = {} @settings = {} + @scopedSettingsStore = new ScopedPropertyStore @configFileHasErrors = false @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson']) @configFilePath ?= path.join(@configDirPath, 'config.cson') @@ -319,29 +324,57 @@ class Config # 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 + # ``` + # + # * `scopeDescriptor` (optional) {Array} of {String}s describing a path from + # the root of the syntax tree to a token. Get one by calling + # {TextEditor::scopesAtCursor}. See {::get} for examples. # * `keyPath` {String} name of the key to observe # * `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: (keyPath, options={}, callback) -> - if _.isFunction(options) - callback = options - options = {} - else + observe: (scopeDescriptor, keyPath, options, callback) -> + args = Array::slice.call(arguments) + if args.length is 2 + # observe(keyPath, callback) + [keyPath, callback, scopeDescriptor, options] = args + else if args.length is 3 and Array.isArray(scopeDescriptor) + # observe(scopeDescriptor, keyPath, callback) + [scopeDescriptor, keyPath, callback, options] = args + else if args.length is 3 and _.isString(scopeDescriptor) and _.isObject(keyPath) + # observe(keyPath, options, callback) # Deprecated! + [keyPath, options, callback, scopeDescriptor] = args + message = "" message = "`callNow` was set to false. Use ::onDidChange instead. Note that ::onDidChange calls back with different arguments." if options.callNow == false - deprecate "Config::observe no longer supports options. #{message}" + deprecate "Config::observe no longer supports options; see https://atom.io/docs/api/latest/Config. #{message}" + else + console.error 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details' + return - callback(_.clone(@get(keyPath))) unless options.callNow == false - @emitter.on 'did-change', (event) -> - callback(event.newValue) if keyPath? and keyPath.indexOf(event?.keyPath) is 0 + if scopeDescriptor? + @observeScopedKeyPath(scopeDescriptor, keyPath, callback) + else + @observeKeyPath(keyPath, 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 + # * `scopeDescriptor` (optional) {Array} of {String}s describing a path from + # the root of the syntax tree to a token. Get one by calling + # {TextEditor::scopesAtCursor}. See {::get} for examples. + # * `keyPath` (optional) {String} name of the key to observe. Must be + # specified if `scopeDescriptor` is specified. # * `callback` {Function} to call when the value of the key changes. # * `event` {Object} # * `newValue` the new value of the key @@ -350,13 +383,17 @@ class Config # # Returns a {Disposable} with the following keys on which you can call # `.dispose()` to unsubscribe. - onDidChange: (keyPath, callback) -> + onDidChange: (scopeDescriptor, keyPath, callback) -> + args = Array::slice.call(arguments) if arguments.length is 1 - callback = keyPath - keyPath = undefined + [callback, scopeDescriptor, keyPath] = args + else if arguments.length is 2 + [keyPath, callback, scopeDescriptor] = args - @emitter.on 'did-change', (event) -> - callback(event) if not keyPath? or (keyPath? and keyPath.indexOf(event?.keyPath) is 0) + if scopeDescriptor? + @onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback) + else + @onDidChangeKeyPath(keyPath, callback) ### Section: Managing Settings @@ -364,29 +401,84 @@ class Config # 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(['source.ruby'], 'editor.tabLength') # => 2 + # ``` + # + # This setting in ruby files might be different than the global tabLength setting + # + # ```coffee + # atom.config.get('editor.tabLength') # => 4 + # atom.config.get(['source.ruby'], 'editor.tabLength') # => 2 + # ``` + # + # Additionally, you can get the setting at the specific cursor position. + # + # ```coffee + # scopeDescriptor = @editor.scopesAtCursor() + # atom.config.get(scopeDescriptor, 'editor.tabLength') # => 2 + # ``` + # + # * `scopeDescriptor` (optional) {Array} of {String}s describing a path from + # the root of the syntax tree to a token. Get one by calling + # {TextEditor::scopesAtCursor} # * `keyPath` The {String} name of the key to retrieve. # # Returns the value from Atom's default settings, the user's configuration # file in the type specified by the configuration schema. - get: (keyPath) -> - value = _.valueForKeyPath(@settings, keyPath) - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + get: (scopeDescriptor, keyPath) -> + if arguments.length == 1 + keyPath = scopeDescriptor + scopeDescriptor = undefined - 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) + if scopeDescriptor? + value = @getRawScopedValue(scopeDescriptor, keyPath) + return value if value? - value + @getRawValue(keyPath) # 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(['source.ruby'], 'editor.tabLength') # => 4 + # atom.config.get(['source.js'], 'editor.tabLength') # => 4 + # + # # Set ruby to 2 + # atom.config.set('source.ruby', 'editor.tabLength', 2) # => true + # + # # Notice it's only set to 2 in the case of ruby + # atom.config.get('editor.tabLength') # => 4 + # atom.config.get(['source.ruby'], 'editor.tabLength') # => 2 + # atom.config.get(['source.js'], 'editor.tabLength') # => 4 + # ``` + # + # * `scope` (optional) {String}. eg. '.source.ruby' # * `keyPath` The {String} name of the key. # * `value` The value of the setting. Passing `undefined` will revert the # setting to the default value. @@ -394,18 +486,27 @@ class Config # 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: (keyPath, value) -> + set: (scope, keyPath, value) -> + if arguments.length < 3 + value = keyPath + keyPath = scope + scope = undefined + unless value == undefined try value = @makeValueConformToSchema(keyPath, value) catch e return false - @setRawValue(keyPath, value) + if scope? + @setRawScopedValue(scope, keyPath, value) + else + @setRawValue(keyPath, value) + @save() unless @configFileHasErrors true - # Extended: Restore the key path to its default value. + # Extended: Restore the global setting at `keyPath` to its default value. # # * `keyPath` The {String} name of the key. # @@ -414,7 +515,7 @@ class Config @set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath)) @get(keyPath) - # Extended: Get the default value of the key path. _Please note_ that in most + # Extended: Get the global default value of the key path. _Please note_ that in most # cases calling this is not necessary! {::get} returns the default value when # a custom value is not specified. # @@ -425,7 +526,7 @@ class Config defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) _.deepClone(defaultValue) - # Extended: Is the key path value its default value? + # Extended: Is the value at `keyPath` its default value? # # * `keyPath` The {String} name of the key. # @@ -434,7 +535,7 @@ class Config isDefault: (keyPath) -> not _.valueForKeyPath(@settings, keyPath)? - # Extended: Retrieve the schema for a specific key path. The shema will tell + # 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. # @@ -450,7 +551,8 @@ class Config schema = schema.properties[key] schema - # Extended: Returns a new {Object} containing all of settings and defaults. + # Extended: Returns a new {Object} containing all of the global settings and + # defaults. This does not include scoped settings. getSettings: -> _.deepExtend(@settings, @defaultSettings) @@ -484,7 +586,7 @@ class Config deprecate 'Config::unobserve no longer does anything. Call `.dispose()` on the object returned by Config::observe instead.' ### - Section: Private + Section: Internal methods used by core ### pushAtKeyPath: (keyPath, value) -> @@ -505,6 +607,34 @@ class Config @set(keyPath, arrayValue) result + setSchema: (keyPath, schema) -> + unless isPlainObject(schema) + 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)) + + load: -> + @initializeConfigDirectory() + @loadUserConfig() + @observeUserConfig() + + ### + Section: Private methods managing the user's config file + ### + initializeConfigDirectory: (done) -> return if fs.existsSync(@configDirPath) @@ -521,11 +651,6 @@ class Config queue.push({sourcePath, destinationPath}) fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true) - load: -> - @initializeConfigDirectory() - @loadUserConfig() - @observeUserConfig() - loadUserConfig: -> unless fs.existsSync(@configFilePath) fs.makeTreeSync(path.dirname(@configFilePath)) @@ -555,33 +680,9 @@ class Config save: -> CSON.writeFileSync(@configFilePath, @settings) - setRawValue: (keyPath, value) -> - defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) - value = undefined if _.isEqual(defaultValue, value) - - oldValue = _.clone(@get(keyPath)) - _.setValueForKeyPath(@settings, keyPath, value) - newValue = @get(keyPath) - @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) - - setRawDefault: (keyPath, value) -> - oldValue = _.clone(@get(keyPath)) - _.setValueForKeyPath(@defaultSettings, keyPath, value) - newValue = @get(keyPath) - @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) - - setRecursive: (keyPath, value) -> - if isPlainObject(value) - keys = if keyPath? then keyPath.split('.') else [] - for key, childValue of value - continue unless value.hasOwnProperty(key) - @setRecursive(keys.concat([key]).join('.'), childValue) - else - try - value = @makeValueConformToSchema(keyPath, value) - @setRawValue(keyPath, value) - catch e - console.warn("'#{keyPath}' could not be set. Attempted value: #{JSON.stringify(value)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + ### + Section: Private methods managing global settings + ### setAll: (newSettings) -> unless isPlainObject(newSettings) @@ -602,6 +703,55 @@ class Config @setRecursive(null, newSettings) unsetUnspecifiedValues(null, @settings) + setRecursive: (keyPath, value) -> + if isPlainObject(value) + keys = if keyPath? then keyPath.split('.') else [] + for key, childValue of value + continue unless value.hasOwnProperty(key) + @setRecursive(keys.concat([key]).join('.'), childValue) + else + try + value = @makeValueConformToSchema(keyPath, value) + @setRawValue(keyPath, value) + catch e + console.warn("'#{keyPath}' could not be set. Attempted value: #{JSON.stringify(value)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") + + getRawValue: (keyPath) -> + value = _.valueForKeyPath(@settings, keyPath) + defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + + if value? + value = _.deepClone(value) + _.defaults(value, defaultValue) if isPlainObject(value) and isPlainObject(defaultValue) + else + value = _.deepClone(defaultValue) + + value + + setRawValue: (keyPath, value) -> + defaultValue = _.valueForKeyPath(@defaultSettings, keyPath) + value = undefined if _.isEqual(defaultValue, value) + + oldValue = _.clone(@get(keyPath)) + _.setValueForKeyPath(@settings, keyPath, value) + newValue = @get(keyPath) + @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) + + observeKeyPath: (keyPath, options, callback) -> + callback(_.clone(@get(keyPath))) unless options.callNow == false + @emitter.on 'did-change', (event) -> + callback(event.newValue) if keyPath? and keyPath.indexOf(event?.keyPath) is 0 + + onDidChangeKeyPath: (keyPath, callback) -> + @emitter.on 'did-change', (event) -> + callback(event) if not keyPath? or (keyPath? and keyPath.indexOf(event?.keyPath) is 0) + + setRawDefault: (keyPath, value) -> + oldValue = _.clone(@get(keyPath)) + _.setValueForKeyPath(@defaultSettings, keyPath, value) + newValue = @get(keyPath) + @emitter.emit 'did-change', {oldValue, newValue, keyPath} unless _.isEqual(newValue, oldValue) + setDefaults: (keyPath, defaults) -> if defaults? and isPlainObject(defaults) keys = if keyPath? then keyPath.split('.') else [] @@ -615,25 +765,6 @@ class Config catch e console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}") - setSchema: (keyPath, schema) -> - unless isPlainObject(schema) - 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 @@ -647,6 +778,71 @@ class Config value = @constructor.executeSchemaEnforcers(keyPath, value, schema) if schema = @getSchema(keyPath) value + ### + Section: Private Scoped Settings + ### + + addScopedSettings: (name, selector, value) -> + if arguments.length < 3 + value = selector + selector = name + name = null + + settingsBySelector = {} + settingsBySelector[selector] = value + disposable = @scopedSettingsStore.addProperties(name, settingsBySelector) + @emitter.emit 'did-change' + new Disposable => + disposable.dispose() + @emitter.emit 'did-change' + + setRawScopedValue: (selector, keyPath, value) -> + if keyPath? + newValue = {} + _.setValueForKeyPath(newValue, keyPath, value) + value = newValue + @addScopedSettings(null, selector, value) + + getRawScopedValue: (scopeDescriptor, keyPath) -> + scopeChain = scopeDescriptor + .map (scope) -> + scope = ".#{scope}" unless scope[0] is '.' + scope + .join(' ') + @scopedSettingsStore.getPropertyValue(scopeChain, keyPath) + + observeScopedKeyPath: (scopeDescriptor, keyPath, callback) -> + oldValue = @get(scopeDescriptor, keyPath) + + callback(oldValue) + + didChange = => + newValue = @get(scopeDescriptor, keyPath) + callback(newValue) unless _.isEqual(oldValue, newValue) + oldValue = newValue + + @emitter.on 'did-change', didChange + + onDidChangeScopedKeyPath: (scopeDescriptor, keyPath, callback) -> + oldValue = @get(scopeDescriptor, keyPath) + didChange = => + newValue = @get(scopeDescriptor, keyPath) + callback({oldValue, newValue, keyPath}) unless _.isEqual(oldValue, newValue) + oldValue = newValue + + @emitter.on 'did-change', didChange + + # TODO: figure out how to change / remove this. The return value is awkward. + # * language mode uses it for one thing. + # * autocomplete uses it for editor.completions + settingsForScopeDescriptor: (scopeDescriptor, keyPath) -> + scopeChain = scopeDescriptor + .map (scope) -> + scope = ".#{scope}" unless scope[0] is '.' + scope + .join(' ') + @scopedSettingsStore.getProperties(scopeChain, keyPath) + # 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. @@ -692,7 +888,7 @@ Config.addSchemaEnforcers 'null': # null sort of isnt supported. It will just unset in this case coerce: (keyPath, value, schema) -> - throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value == null + throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value in [undefined, null] value 'object': diff --git a/src/language-mode.coffee b/src/language-mode.coffee index b1327a2b4..34d7f3032 100644 --- a/src/language-mode.coffee +++ b/src/language-mode.coffee @@ -30,11 +30,11 @@ class LanguageMode # Returns an {Array} of the commented {Ranges}. toggleLineCommentsForBufferRows: (start, end) -> scopes = @editor.scopesForBufferPosition([start, 0]) - properties = atom.syntax.propertiesForScope(scopes, "editor.commentStart")[0] + properties = atom.config.settingsForScopeDescriptor(scopes, 'editor.commentStart')[0] return unless properties - commentStartString = _.valueForKeyPath(properties, "editor.commentStart") - commentEndString = _.valueForKeyPath(properties, "editor.commentEnd") + commentStartString = _.valueForKeyPath(properties, 'editor.commentStart') + commentEndString = _.valueForKeyPath(properties, 'editor.commentEnd') return unless commentStartString @@ -312,7 +312,7 @@ class LanguageMode @editor.setIndentationForBufferRow(bufferRow, desiredIndentLevel) getRegexForProperty: (scopes, property) -> - if pattern = atom.syntax.getProperty(scopes, property) + if pattern = atom.config.get(scopes, property) new OnigRegExp(pattern) increaseIndentRegexForScopes: (scopes) -> diff --git a/src/scoped-properties.coffee b/src/scoped-properties.coffee index 47d239a5a..dc910a6be 100644 --- a/src/scoped-properties.coffee +++ b/src/scoped-properties.coffee @@ -1,4 +1,5 @@ CSON = require 'season' +{CompositeDisposable} = require 'event-kit' module.exports = class ScopedProperties @@ -10,10 +11,12 @@ class ScopedProperties callback(null, new ScopedProperties(scopedPropertiesPath, scopedProperties)) constructor: (@path, @scopedProperties) -> + @propertyDisposable = new CompositeDisposable activate: -> for selector, properties of @scopedProperties - atom.syntax.addProperties(@path, selector, properties) + @propertyDisposable.add atom.config.addScopedSettings(@path, selector, properties) + return deactivate: -> - atom.syntax.removeProperties(@path) + @propertyDisposable.dispose() diff --git a/src/syntax.coffee b/src/syntax.coffee index 09af37c7c..84bb2e0bb 100644 --- a/src/syntax.coffee +++ b/src/syntax.coffee @@ -28,7 +28,6 @@ class Syntax extends GrammarRegistry constructor: -> super(maxTokensPerLine: 100) - @propertyStore = new ScopedPropertyStore serialize: -> {deserializer: @constructor.name, @grammarOverridesByPath} @@ -36,52 +35,22 @@ class Syntax extends GrammarRegistry createToken: (value, scopes) -> new Token({value, scopes}) # Deprecated: Used by settings-view to display snippets for packages - @::accessor 'scopedProperties', -> - deprecate("Use Syntax::getProperty instead") - @propertyStore.propertySets + @::accessor 'propertyStore', -> + deprecate("Do not use this. Use a public method on Config") + atom.config.scopedSettingsStore addProperties: (args...) -> - name = args.shift() if args.length > 2 - [selector, properties] = args - propertiesBySelector = {} - propertiesBySelector[selector] = properties - @propertyStore.addProperties(name, propertiesBySelector) + deprecate 'Consider using atom.config.set() instead. A direct (but private) replacement is available at atom.config.addScopedSettings().' + atom.config.addScopedSettings(args...) removeProperties: (name) -> - @propertyStore.removeProperties(name) + deprecate 'atom.config.addScopedSettings() now returns a disposable you can call .dispose() on' + atom.config.scopedSettingsStore.removeProperties(name) - clearProperties: -> - @propertyStore = new ScopedPropertyStore - - # Public: Get a property for the given scope and key path. - # - # ## Examples - # - # ```coffee - # comment = atom.syntax.getProperty(['.source.ruby'], 'editor.commentStart') - # console.log(comment) # '# ' - # ``` - # - # * `scope` An {Array} of {String} scopes. - # * `keyPath` A {String} key path. - # - # Returns a {String} property value or undefined. getProperty: (scope, keyPath) -> - scopeChain = scope - .map (scope) -> - scope = ".#{scope}" unless scope[0] is '.' - scope - .join(' ') - @propertyStore.getPropertyValue(scopeChain, keyPath) + deprecate 'A direct (but private) replacement is available at atom.config.getRawScopedValue().' + atom.config.getRawScopedValue(scope, keyPath) propertiesForScope: (scope, keyPath) -> - scopeChain = scope - .map (scope) -> - scope = ".#{scope}" unless scope[0] is '.' - scope - .join(' ') - - @propertyStore.getProperties(scopeChain, keyPath) - - cssSelectorFromScopeSelector: (scopeSelector) -> - new ScopeSelector(scopeSelector).toCssSelector() + deprecate 'A direct (but private) replacement is available at atom.config.scopedSettingsForScopeDescriptor().' + atom.config.settingsForScopeDescriptor(scope, keyPath)