From 5038eb7a6882229dc380ebd7361b693e019c86dd Mon Sep 17 00:00:00 2001 From: Dmitry Sadovnychyi Date: Wed, 30 Dec 2015 21:35:05 +0800 Subject: [PATCH 1/5] Complete CSS selectors inside of attributes #13 --- lib/provider.coffee | 43 ++++++++++++++++++++++++++++++++++++--- package.json | 3 +++ spec/provider-spec.coffee | 14 +++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/provider.coffee b/lib/provider.coffee index 08512a4..a76ea0f 100644 --- a/lib/provider.coffee +++ b/lib/provider.coffee @@ -1,9 +1,11 @@ fs = require 'fs' path = require 'path' +css = require 'css' trailingWhitespace = /\s$/ attributePattern = /\s+([a-zA-Z][-a-zA-Z]*)\s*=\s*$/ tagPattern = /<([a-zA-Z][-a-zA-Z]*)(?:\s|$)/ +stylePattern = /]*>([\s\S]*?)<\/style>/gmi module.exports = selector: '.text.html' @@ -128,12 +130,12 @@ module.exports = getAttributeValueCompletions: ({editor, bufferPosition}, prefix) -> tag = @getPreviousTag(editor, bufferPosition) attribute = @getPreviousAttribute(editor, bufferPosition) - values = @getAttributeValues(attribute) + values = @getAttributeValues(attribute, editor) for value in values when not prefix or firstCharsEqual(value, prefix) @buildAttributeValueCompletion(tag, attribute, value) buildAttributeValueCompletion: (tag, attribute, value) -> - if @completions.attributes[attribute].global + if @completions.attributes[attribute]?.global text: value type: 'value' description: "#{value} value for global #{attribute} attribute" @@ -168,7 +170,42 @@ module.exports = attributePattern.exec(line)?[1] - getAttributeValues: (attribute) -> + getLocalStylesheets: (editor) -> + source = editor.getText() + while match = stylePattern.exec(source) + match[1] + + getStylesheetsAtPath: (dir) -> + stylesheets = [] + for file in fs.readdirSync(dir) + filename = path.join(dir, file) + if fs.lstatSync(filename).isDirectory() + stylesheets = stylesheets.concat(@getStylesheetsAtPath(filename)) + else if path.extname(filename).toLowerCase() in ['.css', '.scss', '.less'] + stylesheets.push fs.readFileSync filename, 'utf-8' + return stylesheets + + buildCSSCompletions: (editor) -> + completions = + cls: [] + ids: [] + stylesheets = @getLocalStylesheets(editor) + for p in atom.project.getPaths() + stylesheets = stylesheets.concat @getStylesheetsAtPath p + for stylesheet in stylesheets + for rule in css.parse(stylesheet, {silent: false}).stylesheet.rules + for selector in rule.selectors + if selector.startsWith('.') + completions.cls.push(selector.slice(1)) + else if selector.startsWith('#') + completions.ids.push(selector.slice(1)) + return completions + + getAttributeValues: (attribute, editor) -> + if attribute?.toLowerCase() is 'class' + return @buildCSSCompletions(editor).cls + else if attribute?.toLowerCase() is 'id' + return @buildCSSCompletions(editor).ids attribute = @completions.attributes[attribute] attribute?.attribOption ? [] diff --git a/package.json b/package.json index 21c84c0..ab00cda 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ "devDependencies": { "coffeelint": "^1.9.7", "request": "^2.53.0" + }, + "dependencies": { + "css": "^2.2.1" } } diff --git a/spec/provider-spec.coffee b/spec/provider-spec.coffee index 883b3b1..0684545 100644 --- a/spec/provider-spec.coffee +++ b/spec/provider-spec.coffee @@ -283,3 +283,17 @@ describe "HTML autocompletions", -> args = atom.commands.dispatch.mostRecentCall.args expect(args[0].tagName.toLowerCase()).toBe 'atom-text-editor' expect(args[1]).toBe 'autocomplete-plus:activate' + + it "autocompletes class names within same file", -> + editor.setText('
+ editor.setText('
Date: Tue, 12 Jan 2016 12:15:21 +0800 Subject: [PATCH 2/5] Use atom tokenizer to parse stylesheets --- lib/load-paths-handler.coffee | 107 ++++++++++++++++++++++++++++++++++ lib/provider.coffee | 76 ++++++++++++------------ package.json | 3 +- 3 files changed, 149 insertions(+), 37 deletions(-) create mode 100644 lib/load-paths-handler.coffee diff --git a/lib/load-paths-handler.coffee b/lib/load-paths-handler.coffee new file mode 100644 index 0000000..4399780 --- /dev/null +++ b/lib/load-paths-handler.coffee @@ -0,0 +1,107 @@ +async = require 'async' +fs = require 'fs' +path = require 'path' +{GitRepository} = require 'atom' +{Minimatch} = require 'minimatch' + +PathsChunkSize = 10 +extFilter = ['.css', '.scss', '.less'] +emittedPaths = new Set + +class PathLoader + constructor: (@rootPath, ignoreVcsIgnores, @traverseSymlinkDirectories, @ignoredNames) -> + @paths = [] + @realPathCache = {} + @repo = null + if ignoreVcsIgnores + repo = GitRepository.open(@rootPath, refreshOnWindowFocus: false) + @repo = repo if repo?.relativize(path.join(@rootPath, 'test')) is 'test' + + load: (done) -> + @loadPath @rootPath, => + @flushPaths() + @repo?.destroy() + done() + + isIgnored: (loadedPath) -> + relativePath = path.relative(@rootPath, loadedPath) + if @repo?.isPathIgnored(relativePath) + true + else + for ignoredName in @ignoredNames + return true if ignoredName.match(relativePath) + + pathLoaded: (loadedPath, done) -> + isStylesheet = path.extname(loadedPath).toLowerCase() in extFilter + unless @isIgnored(loadedPath) or emittedPaths.has(loadedPath) or not isStylesheet + @paths.push(loadedPath) + emittedPaths.add(loadedPath) + + if @paths.length is PathsChunkSize + @flushPaths() + done() + + flushPaths: -> + emit('load-stylesheets:stylesheets-found', @paths) + @paths = [] + + loadPath: (pathToLoad, done) -> + return done() if @isIgnored(pathToLoad) + fs.lstat pathToLoad, (error, stats) => + return done() if error? + if stats.isSymbolicLink() + @isInternalSymlink pathToLoad, (isInternal) => + return done() if isInternal + fs.stat pathToLoad, (error, stats) => + return done() if error? + if stats.isFile() + @pathLoaded(pathToLoad, done) + else if stats.isDirectory() + if @traverseSymlinkDirectories + @loadFolder(pathToLoad, done) + else + done() + else + done() + else if stats.isDirectory() + @loadFolder(pathToLoad, done) + else if stats.isFile() + @pathLoaded(pathToLoad, done) + else + done() + + loadFolder: (folderPath, done) -> + fs.readdir folderPath, (error, children=[]) => + async.each( + children, + (childName, next) => + @loadPath(path.join(folderPath, childName), next) + done + ) + + isInternalSymlink: (pathToLoad, done) -> + fs.realpath pathToLoad, @realPathCache, (err, realPath) => + if err + done(false) + else + done(realPath.search(@rootPath) is 0) + +module.exports = (rootPaths, followSymlinks, ignoreVcsIgnores, ignores=[]) -> + ignoredNames = [] + for ignore in ignores when ignore + try + ignoredNames.push(new Minimatch(ignore, matchBase: true, dot: true)) + catch error + console.warn "Error parsing ignore pattern (#{ignore}): #{error.message}" + + async.each( + rootPaths, + (rootPath, next) -> + new PathLoader( + rootPath, + ignoreVcsIgnores, + followSymlinks, + ignoredNames + ).load(next) + @async() + ) diff --git a/lib/provider.coffee b/lib/provider.coffee index a76ea0f..7bdbeb9 100644 --- a/lib/provider.coffee +++ b/lib/provider.coffee @@ -5,11 +5,12 @@ css = require 'css' trailingWhitespace = /\s$/ attributePattern = /\s+([a-zA-Z][-a-zA-Z]*)\s*=\s*$/ tagPattern = /<([a-zA-Z][-a-zA-Z]*)(?:\s|$)/ -stylePattern = /]*>([\s\S]*?)<\/style>/gmi module.exports = selector: '.text.html' disableForSelector: '.text.html .comment' + cssClassScope: 'entity.other.attribute-name.class.css' + cssIdScope: 'entity.other.attribute-name.id.css' filterSuggestions: true getSuggestions: (request) -> @@ -130,7 +131,7 @@ module.exports = getAttributeValueCompletions: ({editor, bufferPosition}, prefix) -> tag = @getPreviousTag(editor, bufferPosition) attribute = @getPreviousAttribute(editor, bufferPosition) - values = @getAttributeValues(attribute, editor) + values = @getAttributeValues(attribute) for value in values when not prefix or firstCharsEqual(value, prefix) @buildAttributeValueCompletion(tag, attribute, value) @@ -148,9 +149,13 @@ module.exports = loadCompletions: -> @completions = {} + @cssCompletions = + cls: [] + ids: [] fs.readFile path.resolve(__dirname, '..', 'completions.json'), (error, content) => @completions = JSON.parse(content) unless error? return + @buildCSSCompletions() getPreviousTag: (editor, bufferPosition) -> {row} = bufferPosition @@ -170,42 +175,41 @@ module.exports = attributePattern.exec(line)?[1] - getLocalStylesheets: (editor) -> - source = editor.getText() - while match = stylePattern.exec(source) - match[1] - - getStylesheetsAtPath: (dir) -> - stylesheets = [] - for file in fs.readdirSync(dir) - filename = path.join(dir, file) - if fs.lstatSync(filename).isDirectory() - stylesheets = stylesheets.concat(@getStylesheetsAtPath(filename)) - else if path.extname(filename).toLowerCase() in ['.css', '.scss', '.less'] - stylesheets.push fs.readFileSync filename, 'utf-8' - return stylesheets - - buildCSSCompletions: (editor) -> - completions = - cls: [] - ids: [] - stylesheets = @getLocalStylesheets(editor) - for p in atom.project.getPaths() - stylesheets = stylesheets.concat @getStylesheetsAtPath p - for stylesheet in stylesheets - for rule in css.parse(stylesheet, {silent: false}).stylesheet.rules - for selector in rule.selectors - if selector.startsWith('.') - completions.cls.push(selector.slice(1)) - else if selector.startsWith('#') - completions.ids.push(selector.slice(1)) - return completions - - getAttributeValues: (attribute, editor) -> + pathLoader: -> + fileNames = [] + + followSymlinks = atom.config.get 'core.followSymlinks' + ignoredNames = atom.config.get('core.ignoredNames') ? [] + ignoreVcsIgnores = atom.config.get('core.excludeVcsIgnoredPaths') + + {Task} = require 'atom' + taskPath = require.resolve('./load-paths-handler') + + task = Task.once taskPath, atom.project.getPaths(), followSymlinks, + ignoreVcsIgnores, ignoredNames, => + for f in fileNames + content = fs.readFileSync(f, 'utf-8') + grammar = atom.grammars.selectGrammar(f) + for line in grammar.tokenizeLines(content) + for token in line + [..., scope] = token.scopes + if scope is @cssClassScope + @cssCompletions.cls.push(token.value) + else if scope is @cssIdScope + @cssCompletions.ids.push(token.value) + + task.on 'load-stylesheets:stylesheets-found', (paths) -> + fileNames.push(paths...) + + buildCSSCompletions: -> + # TODO: parse current file also + @pathLoader() + + getAttributeValues: (attribute) -> if attribute?.toLowerCase() is 'class' - return @buildCSSCompletions(editor).cls + return @cssCompletions.cls else if attribute?.toLowerCase() is 'id' - return @buildCSSCompletions(editor).ids + return @cssCompletions.ids attribute = @completions.attributes[attribute] attribute?.attribOption ? [] diff --git a/package.json b/package.json index ab00cda..305d4b8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "request": "^2.53.0" }, "dependencies": { - "css": "^2.2.1" + "async": "^1.5.1", + "minimatch": "^3.0.0" } } From 140fd49990c49b3fe623a2d9085679c1b2fbccae Mon Sep 17 00:00:00 2001 From: Dmitry Sadovnychyi Date: Tue, 12 Jan 2016 13:53:03 +0800 Subject: [PATCH 3/5] Update CSS completion index of save event --- lib/load-paths-handler.coffee | 12 +++---- lib/main.coffee | 2 ++ lib/provider.coffee | 65 +++++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/lib/load-paths-handler.coffee b/lib/load-paths-handler.coffee index 4399780..6dcfbb7 100644 --- a/lib/load-paths-handler.coffee +++ b/lib/load-paths-handler.coffee @@ -5,11 +5,10 @@ path = require 'path' {Minimatch} = require 'minimatch' PathsChunkSize = 10 -extFilter = ['.css', '.scss', '.less'] emittedPaths = new Set class PathLoader - constructor: (@rootPath, ignoreVcsIgnores, @traverseSymlinkDirectories, @ignoredNames) -> + constructor: (@rootPath, ignoreVcsIgnores, @traverseSymlinkDirectories, @ignoredNames, @extensions) -> @paths = [] @realPathCache = {} @repo = null @@ -32,8 +31,8 @@ class PathLoader return true if ignoredName.match(relativePath) pathLoaded: (loadedPath, done) -> - isStylesheet = path.extname(loadedPath).toLowerCase() in extFilter - unless @isIgnored(loadedPath) or emittedPaths.has(loadedPath) or not isStylesheet + badExtension = path.extname(loadedPath).toLowerCase() not in @extensions + unless @isIgnored(loadedPath) or emittedPaths.has(loadedPath) or badExtension @paths.push(loadedPath) emittedPaths.add(loadedPath) @@ -86,7 +85,7 @@ class PathLoader else done(realPath.search(@rootPath) is 0) -module.exports = (rootPaths, followSymlinks, ignoreVcsIgnores, ignores=[]) -> +module.exports = (rootPaths, followSymlinks, ignoreVcsIgnores, ignores=[], extensions) -> ignoredNames = [] for ignore in ignores when ignore try @@ -101,7 +100,8 @@ module.exports = (rootPaths, followSymlinks, ignoreVcsIgnores, ignores=[]) -> rootPath, ignoreVcsIgnores, followSymlinks, - ignoredNames + ignoredNames, + extensions ).load(next) @async() ) diff --git a/lib/main.coffee b/lib/main.coffee index 2696398..78bbda8 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -3,4 +3,6 @@ provider = require './provider' module.exports = activate: -> provider.loadCompletions() + deactivate: -> provider.deactivate() + getProvider: -> provider diff --git a/lib/provider.coffee b/lib/provider.coffee index 7bdbeb9..3741d80 100644 --- a/lib/provider.coffee +++ b/lib/provider.coffee @@ -1,6 +1,6 @@ fs = require 'fs' path = require 'path' -css = require 'css' +{CompositeDisposable, Task} = require 'atom' trailingWhitespace = /\s$/ attributePattern = /\s+([a-zA-Z][-a-zA-Z]*)\s*=\s*$/ @@ -11,6 +11,9 @@ module.exports = disableForSelector: '.text.html .comment' cssClassScope: 'entity.other.attribute-name.class.css' cssIdScope: 'entity.other.attribute-name.id.css' + cssClassAttr: 'class' + cssIdAttr: 'id' + cssFileExtensions: ['.css', '.scss', '.less', '.html'] filterSuggestions: true getSuggestions: (request) -> @@ -136,7 +139,11 @@ module.exports = @buildAttributeValueCompletion(tag, attribute, value) buildAttributeValueCompletion: (tag, attribute, value) -> - if @completions.attributes[attribute]?.global + if attribute in [@cssClassAttr, @cssIdAttr] + text: value.value + type: attribute + description: "From #{atom.project.relativizePath(value.path)[1]}" + else if @completions.attributes[attribute].global text: value type: 'value' description: "#{value} value for global #{attribute} attribute" @@ -148,14 +155,19 @@ module.exports = descriptionMoreURL: @getLocalAttributeDocsURL(attribute, tag) loadCompletions: -> + @disposables = new CompositeDisposable @completions = {} - @cssCompletions = - cls: [] - ids: [] + @cssCompletions = [] fs.readFile path.resolve(__dirname, '..', 'completions.json'), (error, content) => @completions = JSON.parse(content) unless error? return - @buildCSSCompletions() + + atom.workspace.observeTextEditors (editor) => + @disposables.add editor.onDidSave (e) => + if path.extname(e.path).toLowerCase() in @cssFileExtensions + @cssCompletions = @cssCompletions.filter (c) -> c.path isnt e.path + @updateCSSCompletionsFromFile(e.path) + @pathLoader() getPreviousTag: (editor, bufferPosition) -> {row} = bufferPosition @@ -175,6 +187,18 @@ module.exports = attributePattern.exec(line)?[1] + updateCSSCompletionsFromFile: (fileName) -> + content = fs.readFileSync(fileName, 'utf-8') + grammar = atom.grammars.selectGrammar(fileName) + for line in grammar.tokenizeLines(content) + for token in line + [..., scope] = token.scopes + if scope in [@cssClassScope, @cssIdScope] + @cssCompletions.push + path: fileName + scope: scope + value: token.value + pathLoader: -> fileNames = [] @@ -182,34 +206,21 @@ module.exports = ignoredNames = atom.config.get('core.ignoredNames') ? [] ignoreVcsIgnores = atom.config.get('core.excludeVcsIgnoredPaths') - {Task} = require 'atom' taskPath = require.resolve('./load-paths-handler') task = Task.once taskPath, atom.project.getPaths(), followSymlinks, - ignoreVcsIgnores, ignoredNames, => + ignoreVcsIgnores, ignoredNames, @cssFileExtensions, => for f in fileNames - content = fs.readFileSync(f, 'utf-8') - grammar = atom.grammars.selectGrammar(f) - for line in grammar.tokenizeLines(content) - for token in line - [..., scope] = token.scopes - if scope is @cssClassScope - @cssCompletions.cls.push(token.value) - else if scope is @cssIdScope - @cssCompletions.ids.push(token.value) + @updateCSSCompletionsFromFile(f) task.on 'load-stylesheets:stylesheets-found', (paths) -> fileNames.push(paths...) - buildCSSCompletions: -> - # TODO: parse current file also - @pathLoader() - getAttributeValues: (attribute) -> - if attribute?.toLowerCase() is 'class' - return @cssCompletions.cls - else if attribute?.toLowerCase() is 'id' - return @cssCompletions.ids + if attribute?.toLowerCase() is @cssClassAttr + return (c for c in @cssCompletions when c.scope is @cssClassScope) + else if attribute?.toLowerCase() is @cssIdAttr + return (c for c in @cssCompletions when c.scope is @cssIdScope) attribute = @completions.attributes[attribute] attribute?.attribOption ? [] @@ -225,5 +236,9 @@ module.exports = getGlobalAttributeDocsURL: (attribute) -> "https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/#{attribute}" + deactivate: -> + @disposables.dispose() + firstCharsEqual = (str1, str2) -> + str1 = str1?.value or str1 str1[0].toLowerCase() is str2[0].toLowerCase() From 111b5f39be986a812b4c0d77c1205798acc82e93 Mon Sep 17 00:00:00 2001 From: Dmitry Sadovnychyi Date: Tue, 12 Jan 2016 15:00:54 +0800 Subject: [PATCH 4/5] Fix specs --- spec/provider-spec.coffee | 59 +++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/spec/provider-spec.coffee b/spec/provider-spec.coffee index 0684545..5624155 100644 --- a/spec/provider-spec.coffee +++ b/spec/provider-spec.coffee @@ -1,3 +1,7 @@ +fs = require 'fs' +path = require 'path' +temp = require 'temp' + describe "HTML autocompletions", -> [editor, provider] = [] @@ -284,16 +288,55 @@ describe "HTML autocompletions", -> expect(args[0].tagName.toLowerCase()).toBe 'atom-text-editor' expect(args[1]).toBe 'autocomplete-plus:activate' - it "autocompletes class names within same file", -> - editor.setText('
+ [editor, provider] = [] + + getCompletions = -> + cursor = editor.getLastCursor() + start = cursor.getBeginningOfCurrentWordBufferPosition() + end = cursor.getBufferPosition() + prefix = editor.getTextInRange([start, end]) + request = + editor: editor + bufferPosition: end + scopeDescriptor: cursor.getScopeDescriptor() + prefix: prefix + provider.getSuggestions(request) + + beforeEach -> + waitsForPromise -> atom.packages.activatePackage('autocomplete-html') + waitsForPromise -> atom.packages.activatePackage('language-html') + waitsForPromise -> atom.packages.activatePackage('language-css') + + runs -> + provider = atom.packages.getActivePackage('autocomplete-html').mainModule.getProvider() + + projectDir = fs.realpathSync(temp.mkdirSync('atom-project')) + samplePath = path.join(projectDir, 'sample.html') + fs.writeFileSync(samplePath, """ + +
""") + + atom.project.setPaths([projectDir]) + waitsForPromise -> atom.workspace.open(samplePath) + waitsFor -> provider.cssCompletions.length > 0 + runs -> editor = atom.workspace.getActiveTextEditor() + + it "autocompletes class names within open file", -> + editor.setCursorBufferPosition([7, 12]) completions = getCompletions() expect(completions.length).toBe 1 - expect(completions[0].text).toBe 'test' + expect(completions[0].text).toBe 'test2' - it "autocompletes ids within same file", -> - editor.setText('
+ editor.setCursorBufferPosition([8, 9]) completions = getCompletions() expect(completions.length).toBe 1 - expect(completions[0].text).toBe 'test' + expect(completions[0].text).toBe 'test1' From 91c9ed7d013253f7d556d4a26966442fdd5dafbf Mon Sep 17 00:00:00 2001 From: Dmitry Sadovnychyi Date: Tue, 12 Jan 2016 15:02:35 +0800 Subject: [PATCH 5/5] Add missing dependencies --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 305d4b8..2b60d02 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ }, "devDependencies": { "coffeelint": "^1.9.7", - "request": "^2.53.0" + "request": "^2.53.0", + "temp": "^0.8.3" }, "dependencies": { "async": "^1.5.1",