diff --git a/.gitignore b/.gitignore index e3cf5d6..9d3d10f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store .idea npm-debug.log -node_modules +node_modules/* .github_changelog_generator diff --git a/.travis.yml b/.travis.yml index 27c2805..2494615 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,6 @@ matrix: - os: linux env: ATOM_CHANNEL=beta - - os: osx - env: ATOM_CHANNEL=stable - ### Generic setup follows ### script: - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh @@ -32,10 +29,12 @@ git: sudo: false +dist: trusty + addons: apt: packages: - build-essential - - git - - libgnome-keyring-dev - fakeroot + - git + - libsecret-1-dev diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a10fcc..499d091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,7 @@ Please note that modifications should follow these coding guidelines: - Indent is 2 spaces. -- Code should pass [CoffeeLint](http://www.coffeelint.org/) with the provided - `coffeelint.json` +- Code should pass [ESlint](https://eslint.org/). - Vertical whitespace helps readability, don’t be afraid to use it. diff --git a/lib/main.js b/lib/main.js index 9169672..4f148fb 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,25 +1,58 @@ 'use babel'; -/* eslint-disable import/extensions, import/no-extraneous-dependencies */ +// eslint-disable-next-line import/no-extraneous-dependencies, import/extensions import { CompositeDisposable } from 'atom'; -/* eslint-enable import/extensions, import/no-extraneous-dependencies */ -let helpers = null; -let path = null; +// Dependencies +let fs; +let path; +let helpers; + +// Internal Variables +let bundledCsslintPath; + +const loadDeps = () => { + if (!fs) { + fs = require('fs-plus'); + } + if (!path) { + path = require('path'); + } + if (!helpers) { + helpers = require('atom-linter'); + } +}; export default { activate() { - require('atom-package-deps').install('linter-csslint'); + this.idleCallbacks = new Set(); + let depsCallbackID; + const installLinterCsslintDeps = () => { + this.idleCallbacks.delete(depsCallbackID); + if (!atom.inSpecMode()) { + require('atom-package-deps').install('linter-csslint'); + } + loadDeps(); + + // FIXME: Remove this after a few versions + if (atom.config.get('linter-csslint.disableTimeout')) { + atom.config.unset('linter-csslint.disableTimeout'); + } + }; + depsCallbackID = window.requestIdleCallback(installLinterCsslintDeps); + this.idleCallbacks.add(depsCallbackID); this.subscriptions = new CompositeDisposable(); this.subscriptions.add( - atom.config.observe('linter-csslint.disableTimeout', (value) => { - this.disableTimeout = value; + atom.config.observe('linter-csslint.executablePath', (value) => { + this.executablePath = value; }), ); }, deactivate() { + this.idleCallbacks.forEach(callbackID => window.cancelIdleCallback(callbackID)); + this.idleCallbacks.clear(); this.subscriptions.dispose(); }, @@ -28,77 +61,129 @@ export default { name: 'CSSLint', grammarScopes: ['source.css', 'source.html'], scope: 'file', - lintOnFly: true, - lint(textEditor) { - if (!helpers) { - helpers = require('atom-linter'); - } - if (!path) { - path = require('path'); - } + lintsOnChange: false, + lint: async (textEditor) => { + loadDeps(); const filePath = textEditor.getPath(); const text = textEditor.getText(); - if (text.length === 0) { - return Promise.resolve([]); + if (!filePath || text.length === 0) { + // Empty or unsaved file + return []; } - const parameters = ['--format=json', '-']; - const exec = path.join(__dirname, '..', 'node_modules', 'atomlinter-csslint', 'cli.js'); + + const parameters = [ + '--format=json', + filePath, + ]; + const projectPath = atom.project.relativizePath(filePath)[0]; let cwd = projectPath; - if (!(cwd)) { + if (!cwd) { cwd = path.dirname(filePath); } - const options = { stdin: text, cwd }; - if (this.disableTimeout) { - options.timeout = Infinity; + + const execOptions = { + cwd, + uniqueKey: `linter-csslint::${filePath}`, + timeout: 1000 * 30, // 30 seconds + ignoreExitCode: true, + }; + + const execPath = this.determineExecPath(this.executablePath, projectPath); + + const output = await helpers.exec(execPath, parameters, execOptions); + + if (textEditor.getText() !== text) { + // The editor contents have changed, tell Linter not to update + return null; } - return helpers.execNode(exec, parameters, options).then((output) => { - if (textEditor.getText() !== text) { - // The editor contents have changed, tell Linter not to update - return null; - } - const toReturn = []; - if (output.length < 1) { - // No output, no errors - return toReturn; + const toReturn = []; + + if (output.length < 1) { + // No output, no errors + return toReturn; + } + + let lintResult; + try { + lintResult = JSON.parse(output); + } catch (e) { + const excerpt = 'Invalid response received from CSSLint, check ' + + 'your console for more details.'; + return [{ + severity: 'error', + excerpt, + location: { + file: filePath, + position: helpers.generateRange(textEditor, 0), + }, + }]; + } + + if (lintResult.messages.length < 1) { + // Output, but no errors found + return toReturn; + } + + lintResult.messages.forEach((data) => { + let line; + let col; + if (!(data.line && data.col)) { + // Use the file start if a location wasn't defined + [line, col] = [0, 0]; + } else { + [line, col] = [data.line - 1, data.col - 1]; } - const lintResult = JSON.parse(output); + const severity = data.type === 'error' ? 'error' : 'warning'; - if (lintResult.messages.length < 1) { - // Output, but no errors found - return toReturn; + const msg = { + severity, + excerpt: data.message, + location: { + file: filePath, + position: helpers.generateRange(textEditor, line, col), + }, + }; + if (data.rule.id && data.rule.desc) { + msg.details = `${data.rule.desc} (${data.rule.id})`; + } + if (data.rule.url) { + msg.url = data.rule.url; } - lintResult.messages.forEach((data) => { - let line; - let col; - if (!(data.line && data.col)) { - // Use the file start if a location wasn't defined - [line, col] = [0, 0]; - } else { - [line, col] = [data.line - 1, data.col - 1]; - } - - const msg = { - type: data.type.charAt(0).toUpperCase() + data.type.slice(1), - text: data.message, - filePath, - range: helpers.generateRange(textEditor, line, col), - }; - - if (data.rule.id && data.rule.desc) { - msg.trace = [{ - type: 'Trace', - text: `[${data.rule.id}] ${data.rule.desc}`, - }]; - } - toReturn.push(msg); - }); - return toReturn; + toReturn.push(msg); }); + + return toReturn; }, }; }, + + determineExecPath(givenPath, projectPath) { + let execPath = givenPath; + if (execPath === '') { + // Use the bundled copy of CSSLint + let relativeBinPath = path.join('node_modules', '.bin', 'csslint'); + if (process.platform === 'win32') { + relativeBinPath += '.cmd'; + } + if (!bundledCsslintPath) { + const packagePath = atom.packages.resolvePackagePath('linter-csslint'); + bundledCsslintPath = path.join(packagePath, relativeBinPath); + } + execPath = bundledCsslintPath; + if (projectPath) { + const localCssLintPath = path.join(projectPath, relativeBinPath); + if (fs.existsSync(localCssLintPath)) { + execPath = localCssLintPath; + } + } + } else { + // Normalize any usage of ~ + fs.normalize(execPath); + } + return execPath; + }, }; diff --git a/package.json b/package.json index f202cd1..497798b 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "license": "MIT", "private": true, "configSchema": { - "disableTimeout": { - "type": "boolean", - "description": "Disable the 10 second execution timeout", - "default": false + "executablePath": { + "type": "string", + "default": "", + "description": "If unset a project local install of CSSLint is attempted to be used first, falling back to the bundled version. Requires a full path to `csslint` (e.g.: `/usr/bin/csslint` or `C:\\foo\\bar\\csslint.cmd`)." } }, "engines": { @@ -24,16 +24,18 @@ }, "dependencies": { "atom-linter": "^10.0.0", - "atom-package-deps": "^4.0.1", - "atomlinter-csslint": "0.10.1" + "atom-package-deps": "^4.6.0", + "csslint": "^1.0.5", + "fs-plus": "^3.0.1" }, "devDependencies": { - "eslint": "^3.15.0", - "eslint-config-airbnb-base": "^11.1.0", - "eslint-plugin-import": "^2.2.0" + "eslint": "^4.3.0", + "eslint-config-airbnb-base": "^11.3.1", + "eslint-plugin-import": "^2.7.0", + "jasmine-fix": "^1.3.0" }, "package-deps": [ - "linter" + "linter:2.0.0" ], "scripts": { "lint": "eslint .", @@ -56,13 +58,14 @@ "atom": true }, "env": { - "node": true + "node": true, + "browser": true } }, "providedServices": { "linter": { "versions": { - "1.0.0": "provideLinter" + "2.0.0": "provideLinter" } } } diff --git a/spec/.eslintrc.js b/spec/.eslintrc.js index d343254..47ac7cc 100644 --- a/spec/.eslintrc.js +++ b/spec/.eslintrc.js @@ -1,6 +1,14 @@ module.exports = { env: { jasmine: true, - atomtest: true + atomtest: true, + }, + rules: { + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true + } + ] } }; diff --git a/spec/fixtures/execProject/node_modules/.bin/csslint b/spec/fixtures/execProject/node_modules/.bin/csslint new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/execProject/node_modules/.bin/csslint.cmd b/spec/fixtures/execProject/node_modules/.bin/csslint.cmd new file mode 100644 index 0000000..e69de29 diff --git a/spec/linter-csslint-spec.js b/spec/linter-csslint-spec.js index 1986228..e19682e 100644 --- a/spec/linter-csslint-spec.js +++ b/spec/linter-csslint-spec.js @@ -1,6 +1,12 @@ 'use babel'; import * as path from 'path'; +// eslint-disable-next-line no-unused-vars +import { it, fit, wait, beforeEach, afterEach } from 'jasmine-fix'; +import linterCsslint from '../lib/main'; + +const linterProvider = linterCsslint.provideLinter(); +const lint = linterProvider.lint; const badPath = path.join(__dirname, 'fixtures', 'bad.css'); const goodPath = path.join(__dirname, 'fixtures', 'good.css'); @@ -9,108 +15,103 @@ const emptyPath = path.join(__dirname, 'fixtures', 'empty.css'); const projectPath = path.join(__dirname, 'fixtures', 'project'); const projectBadPath = path.join(projectPath, 'files', 'badWC.css'); -describe('The csslint provider for Linter', () => { - const lint = require('../lib/main.js').provideLinter().lint; +const emptyRulesDetails = 'Rules without any properties specified should be ' + + 'removed. (empty-rules)'; +const emptRulesUrl = 'https://github.com/CSSLint/csslint/wiki/Disallow-empty-rules'; - beforeEach(() => { +describe('The CSSLint provider for Linter', () => { + beforeEach(async () => { atom.workspace.destroyActivePaneItem(); - waitsForPromise(() => - Promise.all([ - atom.packages.activatePackage('linter-csslint'), - atom.packages.activatePackage('language-css'), - ]).then(() => - atom.workspace.open(goodPath), - ), - ); + + await atom.packages.activatePackage('linter-csslint'); + await atom.packages.activatePackage('language-css'); }); describe('checks bad.css and', () => { - let editor = null; - beforeEach(() => - waitsForPromise(() => - atom.workspace.open(badPath).then((openEditor) => { editor = openEditor; }), - ), - ); - - it('finds at least one message', () => - waitsForPromise(() => - lint(editor).then(messages => - expect(messages.length).toBeGreaterThan(0), - ), - ), - ); - - it('verifies the first message', () => - waitsForPromise(() => - lint(editor).then((messages) => { - expect(messages[0].type).toBe('Warning'); - expect(messages[0].text).toBe('Rule is empty.'); - expect(messages[0].filePath).toBe(badPath); - expect(messages[0].range).toEqual([[0, 0], [0, 4]]); - }), - ), - ); + it('verifies the first message', async () => { + const editor = await atom.workspace.open(badPath); + const messages = await lint(editor); + + expect(messages.length).toBe(1); + expect(messages[0].severity).toBe('warning'); + expect(messages[0].excerpt).toBe('Rule is empty.'); + expect(messages[0].details).toBe(emptyRulesDetails); + expect(messages[0].url).toBe(emptRulesUrl); + expect(messages[0].location.file).toBe(badPath); + expect(messages[0].location.position).toEqual([[0, 0], [0, 4]]); + }); }); describe('warns on invalid CSS', () => { - let editor = null; - beforeEach(() => - waitsForPromise(() => - atom.workspace.open(invalidPath).then((openEditor) => { editor = openEditor; }), - ), - ); - - it('finds one message', () => - waitsForPromise(() => - lint(editor).then(messages => - expect(messages.length).toBe(1), - ), - ), - ); - - it('verifies the message', () => - waitsForPromise(() => - lint(editor).then((messages) => { - expect(messages[0].type).toBe('Error'); - expect(messages[0].text).toBe('Unexpected token \'}\' at line 1, col 1.'); - expect(messages[0].filePath).toBe(invalidPath); - expect(messages[0].range).toEqual([[0, 0], [0, 1]]); - }), - ), - ); + it('verifies the message', async () => { + const editor = await atom.workspace.open(invalidPath); + const messages = await lint(editor); + const details = 'This rule looks for recoverable syntax errors. (errors)'; + + expect(messages.length).toBe(1); + expect(messages[0].severity).toBe('error'); + expect(messages[0].excerpt).toBe('Unexpected token \'}\' at line 1, col 1.'); + expect(messages[0].details).toBe(details); + expect(messages[0].url).not.toBeDefined(); + expect(messages[0].location.file).toBe(invalidPath); + expect(messages[0].location.position).toEqual([[0, 0], [0, 1]]); + }); }); - it('finds nothing wrong with a valid file', () => - waitsForPromise(() => - atom.workspace.open(goodPath).then(editor => - lint(editor).then(messages => - expect(messages.length).toEqual(0), - ), - ), - ), - ); - - it('handles an empty file', () => - waitsForPromise(() => - atom.workspace.open(emptyPath).then(editor => - lint(editor).then(messages => - expect(messages.length).toEqual(0), - ), - ), - ), - ); - - it('respects .csslintrc configurations at the project root', () => { + it('finds nothing wrong with a valid file', async () => { + const editor = await atom.workspace.open(goodPath); + const messages = await lint(editor); + + expect(messages.length).toBe(0); + }); + + it('handles an empty file', async () => { + const editor = await atom.workspace.open(emptyPath); + const messages = await lint(editor); + + expect(messages.length).toBe(0); + }); + + it('respects .csslintrc configurations at the project root', async () => { atom.project.addPath(projectPath); - waitsForPromise(() => - atom.workspace.open(projectBadPath).then(editor => - lint(editor).then((messages) => { - expect(messages[0].type).toBeDefined(); - expect(messages[0].type).toEqual('Error'); - expect(messages[0].text).toBeDefined(); - expect(messages[0].text).toEqual('Rule is empty.'); - }), - ), - ); + const editor = await atom.workspace.open(projectBadPath); + const messages = await lint(editor); + + expect(messages.length).toBe(1); + expect(messages[0].severity).toBe('error'); + expect(messages[0].excerpt).toBe('Rule is empty.'); + expect(messages[0].details).toBe(emptyRulesDetails); + expect(messages[0].url).toBe(emptRulesUrl); + expect(messages[0].location.file).toBe(projectBadPath); + expect(messages[0].location.position).toEqual([[0, 0], [0, 4]]); + }); + + describe('dynamically determines where to execute from', () => { + it('uses the bundled version when unspecified and no local version', () => { + const packagePath = atom.packages.resolvePackagePath('linter-csslint'); + let relativeBinPath = path.join('node_modules', '.bin', 'csslint'); + if (process.platform === 'win32') { + relativeBinPath += '.cmd'; + } + + const foundPath = linterCsslint.determineExecPath('', ''); + expect(foundPath).toBe(path.join(packagePath, relativeBinPath)); + }); + + it('finds a local install if it exists', () => { + const execProjectPath = path.join(__dirname, 'fixtures', 'execProject'); + let relativeBinPath = path.join('node_modules', '.bin', 'csslint'); + if (process.platform === 'win32') { + relativeBinPath += '.cmd'; + } + + const foundPath = linterCsslint.determineExecPath('', execProjectPath); + expect(foundPath).toBe(path.join(execProjectPath, relativeBinPath)); + }); + + it('trusts what the user tells it', async () => { + const foundPath = linterCsslint.determineExecPath('foobar', ''); + expect(foundPath).toBe('foobar'); + }); }); });