diff --git a/package.json b/package.json index 054c4e520..68d694fcf 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "open-on-github": "0.38.0", "package-generator": "0.40.0", "release-notes": "0.53.0", - "settings-view": "0.213.0", + "settings-view": "0.213.1", "snippets": "0.95.0", "spell-check": "0.59.0", "status-bar": "0.77.0", diff --git a/spec/config-spec.coffee b/spec/config-spec.coffee index b75a121a1..3b7bd6061 100644 --- a/spec/config-spec.coffee +++ b/spec/config-spec.coffee @@ -1142,8 +1142,8 @@ describe "Config", -> type: 'integer' default: 12 - expect(atom.config.getSchema('foo.baz')).toBeUndefined() - expect(atom.config.getSchema('foo.bar.anInt.baz')).toBeUndefined() + expect(atom.config.getSchema('foo.baz')).toEqual {type: 'any'} + expect(atom.config.getSchema('foo.bar.anInt.baz')).toBe(null) it "respects the schema for scoped settings", -> schema = @@ -1380,6 +1380,10 @@ describe "Config", -> expect(atom.config.set('foo.bar.aString', nope: 'nope')).toBe false expect(atom.config.get('foo.bar.aString')).toBe 'ok' + it 'does not allow setting children of that key-path', -> + expect(atom.config.set('foo.bar.aString.something', 123)).toBe false + expect(atom.config.get('foo.bar.aString')).toBe 'ok' + describe 'when the schema has a "maximumLength" key', -> it "trims the string to be no longer than the specified maximum", -> schema = @@ -1425,6 +1429,47 @@ describe "Config", -> expect(atom.config.get('foo.bar.anInt')).toEqual 12 expect(atom.config.get('foo.bar.nestedObject.nestedBool')).toEqual true + describe "when the value has additionalProperties set to false", -> + it 'does not allow other properties to be set on the object', -> + atom.config.setSchema('foo.bar', + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + additionalProperties: false + ) + + expect(atom.config.set('foo.bar', {anInt: 5, somethingElse: 'ok'})).toBe true + expect(atom.config.get('foo.bar.anInt')).toBe 5 + expect(atom.config.get('foo.bar.somethingElse')).toBeUndefined() + + expect(atom.config.set('foo.bar.somethingElse', {anInt: 5})).toBe false + expect(atom.config.get('foo.bar.somethingElse')).toBeUndefined() + + describe 'when the value has an additionalProperties schema', -> + it 'validates properties of the object against that schema', -> + atom.config.setSchema('foo.bar', + type: 'object' + properties: + anInt: + type: 'integer' + default: 12 + additionalProperties: + type: 'string' + ) + + expect(atom.config.set('foo.bar', {anInt: 5, somethingElse: 'ok'})).toBe true + expect(atom.config.get('foo.bar.anInt')).toBe 5 + expect(atom.config.get('foo.bar.somethingElse')).toBe 'ok' + + expect(atom.config.set('foo.bar.somethingElse', 7)).toBe false + expect(atom.config.get('foo.bar.somethingElse')).toBe 'ok' + + expect(atom.config.set('foo.bar', {anInt: 6, somethingElse: 7})).toBe true + expect(atom.config.get('foo.bar.anInt')).toBe 6 + expect(atom.config.get('foo.bar.somethingElse')).toBe undefined + describe 'when the value has an "array" type', -> beforeEach -> schema = @@ -1438,6 +1483,11 @@ describe "Config", -> atom.config.set 'foo.bar', ['2', '3', '4'] expect(atom.config.get('foo.bar')).toEqual [2, 3, 4] + it 'does not allow setting children of that key-path', -> + expect(atom.config.set('foo.bar.child', 123)).toBe false + expect(atom.config.set('foo.bar.child.grandchild', 123)).toBe false + expect(atom.config.get('foo.bar')).toEqual [1, 2, 3] + describe 'when the value has a "color" type', -> beforeEach -> schema = diff --git a/spec/grammars-spec.coffee b/spec/grammars-spec.coffee index cc975468f..960ce7d52 100644 --- a/spec/grammars-spec.coffee +++ b/spec/grammars-spec.coffee @@ -1,6 +1,7 @@ path = require 'path' fs = require 'fs-plus' temp = require 'temp' +GrammarRegistry = require '../src/grammar-registry' describe "the `grammars` global", -> beforeEach -> @@ -16,6 +17,9 @@ describe "the `grammars` global", -> waitsForPromise -> atom.packages.activatePackage('language-ruby') + waitsForPromise -> + atom.packages.activatePackage('language-git') + afterEach -> atom.packages.deactivatePackages() atom.packages.unloadPackages() @@ -30,6 +34,30 @@ describe "the `grammars` global", -> expect(grammars2.selectGrammar(filePath).name).toBe 'Ruby' describe ".selectGrammar(filePath)", -> + it "always returns a grammar", -> + registry = new GrammarRegistry() + expect(registry.selectGrammar().scopeName).toBe 'text.plain.null-grammar' + + it "selects the text.plain grammar over the null grammar", -> + expect(atom.grammars.selectGrammar('test.txt').scopeName).toBe 'text.plain' + + it "selects a grammar based on the file path case insensitively", -> + expect(atom.grammars.selectGrammar('/tmp/source.coffee').scopeName).toBe 'source.coffee' + expect(atom.grammars.selectGrammar('/tmp/source.COFFEE').scopeName).toBe 'source.coffee' + + describe "on Windows", -> + originalPlatform = null + + beforeEach -> + originalPlatform = process.platform + Object.defineProperty process, 'platform', value: 'win32' + + afterEach -> + Object.defineProperty process, 'platform', value: originalPlatform + + it "normalizes back slashes to forward slashes when matching the fileTypes", -> + expect(atom.grammars.selectGrammar('something\\.git\\config').scopeName).toBe 'source.git-config' + it "can use the filePath to load the correct grammar based on the grammar's filetype", -> waitsForPromise -> atom.packages.activatePackage('language-git') @@ -110,6 +138,23 @@ describe "the `grammars` global", -> expect(-> atom.grammars.selectGrammar(null, '')).not.toThrow() expect(-> atom.grammars.selectGrammar(null, null)).not.toThrow() + describe "when the user has custom grammar file types", -> + it "considers the custom file types as well as those defined in the grammar", -> + atom.config.set('core.customFileTypes', 'source.ruby': ['Cheffile']) + expect(atom.grammars.selectGrammar('build/Cheffile', 'cookbook "postgres"').scopeName).toBe 'source.ruby' + + it "favors user-defined file types over built-in ones of equal length", -> + atom.config.set('core.customFileTypes', + 'source.coffee': ['Rakefile'], + 'source.ruby': ['Cakefile'] + ) + expect(atom.grammars.selectGrammar('Rakefile', '').scopeName).toBe 'source.coffee' + expect(atom.grammars.selectGrammar('Cakefile', '').scopeName).toBe 'source.ruby' + + it "favors grammars with matching first-line-regexps even if custom file types match the file", -> + atom.config.set('core.customFileTypes', 'source.ruby': ['bootstrap']) + expect(atom.grammars.selectGrammar('bootstrap', '#!/usr/bin/env node').scopeName).toBe 'source.js' + describe ".removeGrammar(grammar)", -> it "removes the grammar, so it won't be returned by selectGrammar", -> grammar = atom.grammars.selectGrammar('foo.js') diff --git a/src/config-schema.coffee b/src/config-schema.coffee index d46cdf589..27f436e09 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -26,6 +26,14 @@ module.exports = default: [] items: type: 'string' + customFileTypes: + type: 'object' + default: {} + description: 'Associates scope names (e.g. "source.js") with arrays of file extensions and file names (e.g. ["Somefile", ".js2"])' + additionalProperties: + type: 'array' + items: + type: 'string' themes: type: 'array' default: ['one-dark-ui', 'one-dark-syntax'] diff --git a/src/config.coffee b/src/config.coffee index b713bd024..7c6dc8286 100644 --- a/src/config.coffee +++ b/src/config.coffee @@ -664,13 +664,24 @@ class Config # * `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. + # Returns `null` when the keyPath has no schema specified, but is accessible + # from the root schema. getSchema: (keyPath) -> keys = splitKeyPath(keyPath) schema = @schema for key in keys - break unless schema? - schema = schema.properties?[key] + if schema.type is 'object' + childSchema = schema.properties?[key] + unless childSchema? + if isPlainObject(schema.additionalProperties) + childSchema = schema.additionalProperties + else if schema.additionalProperties is false + return null + else + return {type: 'any'} + else + return null + schema = childSchema schema # Extended: Get the {String} path to the config file being used. @@ -948,8 +959,9 @@ class Config catch e undefined else - value = @constructor.executeSchemaEnforcers(keyPath, value, schema) if schema = @getSchema(keyPath) - value + unless (schema = @getSchema(keyPath))? + throw new Error("Illegal key path #{keyPath}") if schema is false + @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. @@ -1027,6 +1039,10 @@ class Config # order of specification. Then the `*` enforcers will be run, in order of # specification. Config.addSchemaEnforcers + 'any': + coerce: (keyPath, value, schema) -> + value + 'integer': coerce: (keyPath, value, schema) -> value = parseInt(value) @@ -1077,17 +1093,26 @@ Config.addSchemaEnforcers throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value) return value unless schema.properties? + defaultChildSchema = null + allowsAdditionalProperties = true + if isPlainObject(schema.additionalProperties) + defaultChildSchema = schema.additionalProperties + if schema.additionalProperties is false + allowsAdditionalProperties = false + newValue = {} for prop, propValue of value - childSchema = schema.properties[prop] + childSchema = schema.properties[prop] ? defaultChildSchema if childSchema? try newValue[prop] = @executeSchemaEnforcers("#{keyPath}.#{prop}", propValue, childSchema) catch error console.warn "Error setting item in object: #{error.message}" - else + else if allowsAdditionalProperties # Just pass through un-schema'd values newValue[prop] = propValue + else + console.warn "Illegal object key: #{keyPath}.#{prop}" newValue diff --git a/src/grammar-registry.coffee b/src/grammar-registry.coffee index 7b1ef823f..033595dad 100644 --- a/src/grammar-registry.coffee +++ b/src/grammar-registry.coffee @@ -1,7 +1,11 @@ +_ = require 'underscore-plus' {Emitter} = require 'event-kit' {includeDeprecatedAPIs, deprecate} = require 'grim' FirstMate = require 'first-mate' Token = require './token' +fs = require 'fs-plus' + +PathSplitRegex = new RegExp("[/.]") # Extended: Syntax class holding the grammars used for tokenizing. # @@ -39,7 +43,7 @@ class GrammarRegistry extends FirstMate.GrammarRegistry bestMatch = null highestScore = -Infinity for grammar in @grammars - score = grammar.getScore(filePath, fileContents) + score = @getGrammarScore(grammar, filePath, fileContents) if score > highestScore or not bestMatch? bestMatch = grammar highestScore = score @@ -47,6 +51,90 @@ class GrammarRegistry extends FirstMate.GrammarRegistry bestMatch = grammar unless grammar.bundledPackage bestMatch + # Extended: Returns a {Number} representing how well the grammar matches the + # `filePath` and `contents`. + getGrammarScore: (grammar, filePath, contents) -> + contents = fs.readFileSync(filePath, 'utf8') if not contents? and fs.isFileSync(filePath) + + if @grammarOverrideForPath(filePath) is grammar.scopeName + 2 + (filePath?.length ? 0) + else if @grammarMatchesContents(grammar, contents) + 1 + (filePath?.length ? 0) + else + @getGrammarPathScore(grammar, filePath) + + getGrammarPathScore: (grammar, filePath) -> + return -1 unless filePath + filePath = filePath.replace(/\\/g, '/') if process.platform is 'win32' + + pathComponents = filePath.toLowerCase().split(PathSplitRegex) + pathScore = -1 + + fileTypes = grammar.fileTypes + if customFileTypes = atom.config.get('core.customFileTypes')?[grammar.scopeName] + fileTypes = fileTypes.concat(customFileTypes) + + for fileType, i in fileTypes + fileTypeComponents = fileType.toLowerCase().split(PathSplitRegex) + pathSuffix = pathComponents[-fileTypeComponents.length..-1] + if _.isEqual(pathSuffix, fileTypeComponents) + pathScore = Math.max(pathScore, fileType.length) + if i >= grammar.fileTypes.length + pathScore += 0.5 + + pathScore + + grammarMatchesContents: (grammar, contents) -> + return false unless contents? and grammar.firstLineRegex? + + escaped = false + numberOfNewlinesInRegex = 0 + for character in grammar.firstLineRegex.source + switch character + when '\\' + escaped = not escaped + when 'n' + numberOfNewlinesInRegex++ if escaped + escaped = false + else + escaped = false + lines = contents.split('\n') + grammar.firstLineRegex.testSync(lines[0..numberOfNewlinesInRegex].join('\n')) + + # Public: Get the grammar override for the given file path. + # + # * `filePath` A {String} file path. + # + # Returns a {Grammar} or undefined. + grammarOverrideForPath: (filePath) -> + @grammarOverridesByPath[filePath] + + # Public: Set the grammar override for the given file path. + # + # * `filePath` A non-empty {String} file path. + # * `scopeName` A {String} such as `"source.js"`. + # + # Returns a {Grammar} or undefined. + setGrammarOverrideForPath: (filePath, scopeName) -> + if filePath + @grammarOverridesByPath[filePath] = scopeName + + # Public: Remove the grammar override for the given file path. + # + # * `filePath` A {String} file path. + # + # Returns undefined. + clearGrammarOverrideForPath: (filePath) -> + delete @grammarOverridesByPath[filePath] + undefined + + # Public: Remove all grammar overrides. + # + # Returns undefined. + clearGrammarOverrides: -> + @grammarOverridesByPath = {} + undefined + clearObservers: -> @off() if includeDeprecatedAPIs @emitter = new Emitter diff --git a/src/tokenized-buffer.coffee b/src/tokenized-buffer.coffee index 473e74cf5..4b260ebf5 100644 --- a/src/tokenized-buffer.coffee +++ b/src/tokenized-buffer.coffee @@ -68,7 +68,7 @@ class TokenizedBuffer extends Model if grammar.injectionSelector? @retokenizeLines() if @hasTokenForSelector(grammar.injectionSelector) else - newScore = grammar.getScore(@buffer.getPath(), @getGrammarSelectionContent()) + newScore = atom.grammars.getGrammarScore(grammar, @buffer.getPath(), @getGrammarSelectionContent()) @setGrammar(grammar, newScore) if newScore > @currentGrammarScore setGrammar: (grammar, score) -> @@ -76,7 +76,7 @@ class TokenizedBuffer extends Model @grammar = grammar @rootScopeDescriptor = new ScopeDescriptor(scopes: [@grammar.scopeName]) - @currentGrammarScore = score ? grammar.getScore(@buffer.getPath(), @getGrammarSelectionContent()) + @currentGrammarScore = score ? atom.grammars.getGrammarScore(grammar, @buffer.getPath(), @getGrammarSelectionContent()) @grammarUpdateDisposable?.dispose() @grammarUpdateDisposable = @grammar.onDidUpdate => @retokenizeLines()