Merge pull request #8144 from atom/mb-custom-extension-grammar-map

Allow custom association between file names and grammars
This commit is contained in:
Max Brunsfeld
2015-08-13 10:17:29 -07:00
7 changed files with 229 additions and 13 deletions

View File

@@ -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",

View File

@@ -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 =

View File

@@ -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')

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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()