├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.js ├── lib ├── fix.js ├── fix.test.js ├── format.js ├── format.test.js ├── get-package-data.js ├── get-package-data.test.js ├── get-package-path.js ├── get-package-path.test.js ├── get-xo.js ├── get-xo.test.js ├── lint.js ├── lint.test.js ├── worker.js ├── worker.test.js └── xo-helper.js ├── license ├── mocks ├── delegated │ ├── disabled │ │ ├── node_modules │ │ │ └── xo │ │ │ │ └── index.js │ │ └── package.json │ ├── enabled │ │ ├── node_modules │ │ │ └── xo │ │ │ │ └── index.js │ │ └── package.json │ ├── node_modules │ │ └── xo │ │ │ └── index.js │ └── package.json ├── enabled │ ├── bad.js │ ├── empty.js │ ├── fixable.js │ ├── good.js │ ├── package.json │ ├── relative-path.js │ ├── save-fixable-default.js │ ├── save-fixable.js │ └── some-export.js ├── example.js ├── index.js ├── local │ ├── node_modules │ │ └── xo │ │ │ └── index.js │ └── package.json ├── mock-editor.js └── mock-task.js ├── package.json ├── readme.md ├── screenshot.png └── spec └── linter-xo-spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test-node: 7 | name: Node.js 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: 12 16 | - run: npm install 17 | - run: npm test 18 | - uses: codecov/codecov-action@v1 19 | test-atom: 20 | name: ${{ matrix.channel }} - ${{ matrix.os }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: 26 | - ubuntu-latest 27 | - macos-latest 28 | channel: 29 | - stable 30 | - beta 31 | steps: 32 | - uses: actions/checkout@v2 33 | - uses: UziTech/action-setup-atom@v2 34 | with: 35 | version: ${{ matrix.channel }} 36 | - if: matrix.os == 'windows-latest' 37 | uses: microsoft/setup-msbuild@v1.0.2 38 | with: 39 | vs-version: '[14.0,)' 40 | - name: Install dependencies 41 | run: apm install 42 | - name: Run tests 43 | run: atom --test spec 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .nyc_output 4 | coverage 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Disposable} = require('atom'); 2 | const fix = require('./lib/fix.js'); 3 | const format = require('./lib/format.js'); 4 | const {lint, startWorker, stopWorker} = require('./lib/worker.js'); 5 | 6 | const SUPPORTED_SCOPES = [ 7 | 'source.js', 8 | 'source.jsx', 9 | 'source.js.jsx', 10 | 'source.ts', 11 | 'source.tsx' 12 | ]; 13 | 14 | function install() { 15 | const callbackId = window.requestIdleCallback(() => { 16 | require('atom-package-deps').install('linter-xo'); 17 | startWorker(); 18 | }); 19 | 20 | return new Disposable(() => { 21 | window.cancelIdleCallback(callbackId); 22 | }); 23 | } 24 | 25 | module.exports.activate = function () { 26 | this.subscriptions = new CompositeDisposable(); 27 | 28 | this.subscriptions.add(atom.commands.add('atom-text-editor', { 29 | 'XO:Fix': () => { 30 | const editor = atom.workspace.getActiveTextEditor(); 31 | 32 | if (!editor) { 33 | return; 34 | } 35 | 36 | fix(editor, lint)(editor.getText()); 37 | } 38 | })); 39 | 40 | this.subscriptions.add(atom.workspace.observeTextEditors(editor => { 41 | editor.getBuffer().onWillSave(() => { 42 | if (!atom.config.get('linter-xo.fixOnSave')) { 43 | return; 44 | } 45 | 46 | const {scopeName} = editor.getGrammar(); 47 | 48 | if (!SUPPORTED_SCOPES.includes(scopeName)) { 49 | return; 50 | } 51 | 52 | return fix(editor, lint)(editor.getText(), atom.config.get('linter-xo.rulesToDisableWhileFixingOnSave')); 53 | }); 54 | })); 55 | 56 | this.subscriptions.add(install()); 57 | }; 58 | 59 | module.exports.config = { 60 | fixOnSave: { 61 | type: 'boolean', 62 | default: false 63 | }, 64 | rulesToDisableWhileFixingOnSave: { 65 | title: 'Disable specific rules while fixing on save', 66 | description: 'Prevent rules from being auto-fixed by XO. Applies to fixes made on save but not when running the `XO:Fix` command.', 67 | type: 'array', 68 | default: [ 69 | 'capitalized-comments', 70 | 'ava/no-only-test', 71 | 'ava/no-skip-test' 72 | ], 73 | items: { 74 | type: 'string' 75 | } 76 | } 77 | }; 78 | 79 | module.exports.deactivate = function () { 80 | this.subscriptions.dispose(); 81 | stopWorker(); 82 | }; 83 | 84 | module.exports.provideLinter = function () { 85 | return { 86 | name: 'XO', 87 | grammarScopes: SUPPORTED_SCOPES, 88 | scope: 'file', 89 | lintsOnChange: true, 90 | lint: async editor => { 91 | const result = await lint(editor.getPath())(editor.getText()); 92 | return format(editor)(result); 93 | } 94 | }; 95 | }; 96 | -------------------------------------------------------------------------------- /lib/fix.js: -------------------------------------------------------------------------------- 1 | function disableRules(ids = []) { 2 | const rules = {}; 3 | for (const id of ids) { 4 | rules[id] = 0; 5 | } 6 | 7 | return rules; 8 | } 9 | 10 | // (editor: Object) => function 11 | function fix(editor, lint) { 12 | // (text: string) => Promise 13 | return async (text, exclude) => { 14 | const rules = disableRules(exclude); 15 | const report = await lint(editor.getPath())(text, {fix: true, rules}); 16 | const [result] = report.results; 17 | 18 | // No results are returned when the file is ignored 19 | if (result && result.output) { 20 | editor.getBuffer().setTextViaDiff(result.output); 21 | } 22 | }; 23 | } 24 | 25 | module.exports = fix; 26 | -------------------------------------------------------------------------------- /lib/fix.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const {editors} = require('../mocks/index.js'); 3 | const fix = require('./fix.js'); 4 | const lint = require('./lint.js'); 5 | 6 | test('fix file without problems', async t => { 7 | const input = 'console.log(\'No problems.\');\n'; 8 | const editor = editors.enabled(input); 9 | await fix(editor, lint)(editor.getText()); 10 | const actual = editor.getText(); 11 | t.is(actual, input, 'should leave input untouched'); 12 | }); 13 | 14 | test('fix file with problems', async t => { 15 | const input = 'console.log(\'Some problems.\');;;'; 16 | const editor = editors.enabled(input); 17 | await fix(editor, lint)(editor.getText()); 18 | const actual = editor.getText(); 19 | const expected = 'console.log(\'Some problems.\');\n'; 20 | t.is(actual, expected, 'should fix output'); 21 | }); 22 | 23 | test('exclude rules from fixing', async t => { 24 | const input = '//some uncapitalized comment'; 25 | const editor = editors.enabled(input); 26 | await fix(editor, lint)(editor.getText(), ['capitalized-comments']); 27 | const actual = editor.getText(); 28 | const expected = '// some uncapitalized comment\n'; 29 | t.is(actual, expected, 'should fix output, but not capitalized the comment'); 30 | }); 31 | -------------------------------------------------------------------------------- /lib/format.js: -------------------------------------------------------------------------------- 1 | const {Range} = require('atom'); 2 | const {generateRange} = require('atom-linter'); 3 | const ruleURI = require('eslint-rule-documentation'); 4 | 5 | // (editor: Object, message: Object) => Array 6 | function selectSolutions(editor, message) { 7 | const {fix} = message; 8 | const buffer = editor.getBuffer(); 9 | 10 | if (!fix) { 11 | return []; 12 | } 13 | 14 | const {range, text} = fix; 15 | 16 | if (!Array.isArray(range)) { 17 | return []; 18 | } 19 | 20 | if (typeof text !== 'string') { 21 | return []; 22 | } 23 | 24 | const position = new Range(...range.map(index => buffer.positionForCharacterIndex(index))); 25 | return [{position, replaceWith: text}]; 26 | } 27 | 28 | // (message: Object) => string 29 | function selectUrl(message) { 30 | const result = ruleURI(message.ruleId || ''); 31 | return result.found ? result.url : ''; 32 | } 33 | 34 | // (message: Object) => string 35 | function selectExcerpt(message) { 36 | return message.message; 37 | } 38 | 39 | // (editor: Object, message: Object) => Array 40 | function selectLocation(editor, x) { 41 | return { 42 | file: editor.getPath(), 43 | position: selectPosition(editor, x) 44 | }; 45 | } 46 | 47 | // (editor: Object, message: Object) => Array 48 | function selectPosition(editor, x) { 49 | const messageLine = x.line - 1; 50 | 51 | if (typeof x.endColumn === 'number' && typeof x.endLine === 'number') { 52 | const messageColumn = Math.max(0, x.column - 1); 53 | return [[messageLine, messageColumn], [x.endLine - 1, x.endColumn - 1]]; 54 | } 55 | 56 | if (typeof x.line === 'number' && typeof x.column === 'number') { 57 | // We want msgCol to remain undefined if it was intentional so 58 | // `generateRange` will give us a range over the entire line 59 | const messageColumn = typeof x.column === 'undefined' ? x.column : x.column - 1; 60 | 61 | try { 62 | return generateRange(editor, messageLine, messageColumn); 63 | } catch { 64 | throw new Error(`Failed getting range. This is most likely an issue with ESLint. (${x.ruleId} - ${x.message} at ${x.line}:${x.column})`); 65 | } 66 | } 67 | } 68 | 69 | // (message: Object) => string 70 | function selectSeverity(message) { 71 | return message.severity === 2 ? 'error' : 'warning'; 72 | } 73 | 74 | // (editor: Object) => function 75 | function format(editor) { 76 | // (report?: Object) => Array 77 | return report => { 78 | if (!report) { 79 | return []; 80 | } 81 | 82 | const {results = []} = report; 83 | const [result] = results; 84 | 85 | if (!result) { 86 | return []; 87 | } 88 | 89 | const {messages} = result; 90 | 91 | if (!messages) { 92 | return []; 93 | } 94 | 95 | return messages.map(message => { 96 | return { 97 | location: selectLocation(editor, message), 98 | url: selectUrl(message), 99 | excerpt: selectExcerpt(message), 100 | severity: selectSeverity(message), 101 | solutions: selectSolutions(editor, message) 102 | }; 103 | }); 104 | }; 105 | } 106 | 107 | module.exports = format; 108 | -------------------------------------------------------------------------------- /lib/format.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const proxyquire = require('proxyquire'); 3 | const {Range} = require('text-buffer'); 4 | const MockEditor = require('../mocks/mock-editor.js'); 5 | 6 | const stubs = { 7 | atom: { 8 | Range, 9 | '@noCallThru': true, 10 | '@global': true 11 | } 12 | }; 13 | 14 | const editor = new MockEditor({path: '__fake'}); 15 | 16 | const format = proxyquire('./format', stubs); 17 | 18 | test('for an empty report', t => { 19 | const actual = format(editor)(); 20 | const expected = []; 21 | t.deepEqual(actual, expected, 'it should return an empty array'); 22 | }); 23 | 24 | test('for a report without results', t => { 25 | const actual = format(editor)({}); 26 | const expected = []; 27 | t.deepEqual(actual, expected, 'it should return an empty array'); 28 | }); 29 | 30 | test('for a report with empty results', t => { 31 | const actual = format(editor)({results: []}); 32 | const expected = []; 33 | t.deepEqual(actual, expected, 'it should return an empty array'); 34 | }); 35 | 36 | test('for a report with an error message', t => { 37 | const input = { 38 | results: [ 39 | { 40 | messages: [ 41 | { 42 | message: 'message', 43 | severity: 2 44 | } 45 | ] 46 | } 47 | ] 48 | }; 49 | 50 | const [actual] = format(editor)(input); 51 | t.is(actual.severity, 'error', 'it should return an Error'); 52 | t.is(actual.excerpt, 'message', 'it should return appropriate excerpt'); 53 | }); 54 | 55 | test('for a report with an error message with a location', t => { 56 | const input = { 57 | results: [ 58 | { 59 | messages: [ 60 | { 61 | message: 'message', 62 | severity: 2, 63 | line: 1, 64 | column: 1, 65 | endLine: 1, 66 | endColumn: 2 67 | } 68 | ] 69 | } 70 | ] 71 | }; 72 | 73 | const [{location: actual}] = format(editor)(input); 74 | const expected = { 75 | file: '__fake', 76 | position: [[0, 0], [0, 1]] 77 | }; 78 | t.deepEqual(actual, expected, 'it should return the correct location'); 79 | }); 80 | 81 | test('for a report with fixes', t => { 82 | const input = { 83 | results: [ 84 | { 85 | messages: [ 86 | { 87 | fix: { 88 | range: [0, 0], 89 | text: ';' 90 | } 91 | } 92 | ] 93 | } 94 | ] 95 | }; 96 | 97 | const [{solutions: [actual]}] = format(editor)(input); 98 | const expected = { 99 | position: { 100 | end: {column: 0, row: 0}, 101 | start: {column: 0, row: 0} 102 | }, 103 | replaceWith: ';' 104 | }; 105 | t.like(actual, expected, 'it should return the correct location'); 106 | }); 107 | -------------------------------------------------------------------------------- /lib/get-package-data.js: -------------------------------------------------------------------------------- 1 | const loadJsonFile = require('load-json-file'); 2 | const getPackagePath = require('./get-package-path.js'); 3 | 4 | // (base: string) => pkg: Promise 5 | async function getPackageData(base) { 6 | const packagePath = await getPackagePath(base); 7 | return packagePath ? loadJsonFile(packagePath) : {}; 8 | } 9 | 10 | module.exports = getPackageData; 11 | -------------------------------------------------------------------------------- /lib/get-package-data.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const {paths} = require('../mocks/index.js'); 4 | const getPackageData = require('./get-package-data.js'); 5 | 6 | test('in enabled workspace', async t => { 7 | const {name: actual} = await getPackageData(paths.enabled); 8 | t.is(actual, 'enabled', 'it should return manifest in enabled workspace'); 9 | }); 10 | 11 | test('in delegated workspace', async t => { 12 | const {name: actual} = await getPackageData(paths['delegated/disabled']); 13 | t.is(actual, 'delegated', 'it should return manifest in disabled workspace'); 14 | }); 15 | 16 | test('in disabled workspace', async t => { 17 | const actual = await getPackageData(path.dirname(paths.disabled)); 18 | t.deepEqual(actual, {}, 'it should return manifest in disabled workspace'); 19 | }); 20 | 21 | test('in nested workspace', async t => { 22 | const {name: actual} = await getPackageData(paths['delegated/enabled']); 23 | t.is(actual, 'delegated/enabled', 'it should return manifest in disabled workspace'); 24 | }); 25 | -------------------------------------------------------------------------------- /lib/get-package-path.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const loadJsonFile = require('load-json-file'); 3 | const pkgUp = require('pkg-up'); 4 | 5 | const DEFAULT_PACKAGE = {xo: false}; 6 | 7 | // (base: string) => pkgPath: Promise 8 | async function findPackage(base) { 9 | const pkgPath = await pkgUp({cwd: base}); 10 | const pkg = await loadPkg(pkgPath); 11 | 12 | if (pkg.xo === false && pkgPath !== null) { 13 | const newBase = path.resolve(path.dirname(pkgPath), '..'); 14 | return getPackagePath(newBase); 15 | } 16 | 17 | return pkgPath; 18 | } 19 | 20 | // (file: string) => pkg: Promise 21 | async function loadPkg(file) { 22 | try { 23 | return await loadJsonFile(file); 24 | } catch { 25 | return DEFAULT_PACKAGE; 26 | } 27 | } 28 | 29 | // (base: string) => pkgPath: Promise 30 | async function getPackagePath(base) { 31 | return findPackage(base); 32 | } 33 | 34 | module.exports = getPackagePath; 35 | -------------------------------------------------------------------------------- /lib/get-package-path.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const {paths} = require('../mocks/index.js'); 4 | const getPackagePath = require('./get-package-path.js'); 5 | 6 | test('in enabled workspace', async t => { 7 | const actual = await getPackagePath(paths.enabled); 8 | const expected = path.resolve(paths.enabled, 'package.json'); 9 | t.is(actual, expected, 'it should return path to manifest in enabled workspace'); 10 | }); 11 | 12 | test('in disabled workspace', async t => { 13 | const actual = await getPackagePath(paths.disabled); 14 | t.is(actual, null, 'it should return path to manifest in disabled workspace'); 15 | }); 16 | 17 | test('in delegated workspace', async t => { 18 | const actual = await getPackagePath(paths['delegated/disabled']); 19 | const expected = path.resolve(paths.delegated, 'package.json'); 20 | t.is(actual, expected, 'it should return path to manifest in delegated workspace'); 21 | }); 22 | 23 | test('in nested workspace', async t => { 24 | const actual = await getPackagePath(paths['delegated/enabled']); 25 | const expected = path.resolve(paths['delegated/enabled'], 'package.json'); 26 | t.is(actual, expected, 'it should return path to manifest in nested workspace'); 27 | }); 28 | 29 | test('in no workspace', async t => { 30 | const actual = await getPackagePath('/'); 31 | t.is(actual, null, 'it should return null'); 32 | }); 33 | -------------------------------------------------------------------------------- /lib/get-xo.js: -------------------------------------------------------------------------------- 1 | const resolveFrom = require('resolve-from'); 2 | const getPackagePath = require('./get-package-path.js'); 3 | 4 | // (base: string, id: string) => resolved: string 5 | function degradingResolve(base, id) { 6 | return base ? 7 | resolveFrom.silent(base, id) || require.resolve(id) : 8 | require.resolve(id); 9 | } 10 | 11 | // (base: string) => xo: Promise 12 | async function getXO(base) { 13 | const resolvedBase = base ? await getPackagePath(base) : __dirname; 14 | const resolvedPath = degradingResolve(resolvedBase, 'xo'); 15 | return require(resolvedPath); 16 | } 17 | 18 | module.exports = getXO; 19 | -------------------------------------------------------------------------------- /lib/get-xo.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const {paths} = require('../mocks/index.js'); 3 | const getXO = require('./get-xo.js'); 4 | 5 | test('in workspace with locally resolvable xo', async t => { 6 | const xo = await getXO(paths.local); 7 | t.is(xo.__test_name, 'local', 'it should return local resolable xo'); 8 | }); 9 | 10 | test('in workspace without locally resolvable xo', async t => { 11 | const xo = await getXO(paths.enabled); 12 | t.false('__test_name' in xo, 'it should return builtin xo'); 13 | }); 14 | 15 | test('in delegated workspace with locally resolvable xo', async t => { 16 | const xo = await getXO(paths['delegated/disabled']); 17 | t.is(xo.__test_name, 'delegated', 'it should return delegated locally resolveable xo'); 18 | }); 19 | 20 | test('in nested workspace with locally resolvable xo', async t => { 21 | const xo = await getXO(paths['delegated/enabled']); 22 | t.is(xo.__test_name, 'delegated/enabled', 'it should return nested locally resolveable xo'); 23 | }); 24 | -------------------------------------------------------------------------------- /lib/lint.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const getPackageData = require('./get-package-data.js'); 3 | const getPackagePath = require('./get-package-path.js'); 4 | const getXO = require('./get-xo.js'); 5 | 6 | const EMPTY_REPORT = { 7 | results: [ 8 | { 9 | messages: [] 10 | } 11 | ] 12 | }; 13 | 14 | async function getCWD(base) { 15 | const pkgPath = await getPackagePath(base); 16 | return pkgPath ? path.dirname(pkgPath) : base; 17 | } 18 | 19 | // (base: string) => depends: Promise 20 | async function dependsOnXO(base) { 21 | const {dependencies = {}, devDependencies = {}} = await getPackageData(base); 22 | return 'xo' in dependencies || 'xo' in devDependencies; 23 | } 24 | 25 | // (filename: string) => function 26 | function lint(filename) { 27 | if (!filename) { 28 | return async () => EMPTY_REPORT; 29 | } 30 | 31 | const fileDirectory = path.dirname(filename); 32 | 33 | const pendingContext = Promise.all([ 34 | getCWD(fileDirectory), 35 | dependsOnXO(fileDirectory), 36 | getXO(fileDirectory) 37 | ]); 38 | 39 | // (editorText: string, options?: Object) => Promise 40 | return async (editorText, options) => { 41 | const [cwd, depends, xo] = await pendingContext; 42 | 43 | if (!depends) { 44 | return EMPTY_REPORT; 45 | } 46 | 47 | // Ugly hack to workaround ESLint's lack of a `cwd` option 48 | // TODO: remove this when https://github.com/sindresorhus/atom-linter-xo/issues/19 is resolved 49 | const previous = process.cwd(); 50 | process.chdir(cwd); 51 | 52 | const report = xo.lintText(editorText, { 53 | cwd: fileDirectory, 54 | ...(filename ? {filename, cwd} : {}), 55 | ...options 56 | }); 57 | 58 | process.chdir(previous); 59 | return report; 60 | }; 61 | } 62 | 63 | module.exports = lint; 64 | -------------------------------------------------------------------------------- /lib/lint.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const {editors} = require('../mocks/index.js'); 3 | const lint = require('./lint.js'); 4 | 5 | test('file without problems', async t => { 6 | const editor = editors.enabled('console.log(\'No problems.\');\n'); 7 | const {results: [{messages}]} = await lint(editor.getPath())(editor.getText()); 8 | t.deepEqual(messages, [], 'it should report no errors'); 9 | }); 10 | 11 | test('file with a problem', async t => { 12 | const editor = editors.enabled('console.log(\'One problem\');'); 13 | const {results: [{messages}]} = await lint(editor.getPath())(editor.getText()); 14 | t.is(messages.length, 1, 'it should report one error if enabled'); 15 | }); 16 | 17 | test('file with a problem in delegated workspace', async t => { 18 | const editor = editors.delegated('console.log(\'One problem\');'); 19 | const {results: [{messages}]} = await lint(editor.getPath())(editor.getText()); 20 | t.is(messages.length, 1, 'it should report one error if delegated'); 21 | }); 22 | 23 | test('file with a problem in disabled workspace', async t => { 24 | const editor = editors.disabled('console.log(\'One problem\');'); 25 | const {results: [{messages}]} = await lint(editor.getPath())(editor.getText()); 26 | t.is(messages.length, 0, 'it should report no errors'); 27 | }); 28 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | const {CompositeDisposable, Task} = require('atom'); 2 | const pLimit = require('p-limit'); 3 | const uniqueString = require('unique-string'); 4 | 5 | const limit = pLimit(1); 6 | let worker; 7 | 8 | module.exports.startWorker = startWorker; 9 | module.exports.stopWorker = stopWorker; 10 | 11 | function startWorker(createWorker) { 12 | if (worker) { 13 | if (worker.childProcess.connected) { 14 | return; 15 | } 16 | 17 | stopWorker(); 18 | } 19 | 20 | worker = createWorker ? createWorker() : new Task(require.resolve('./xo-helper.js')); 21 | 22 | worker.start([]); 23 | } 24 | 25 | function stopWorker() { 26 | if (worker) { 27 | worker.terminate(); 28 | worker = undefined; 29 | } 30 | } 31 | 32 | async function sendJob(config) { 33 | config.id = uniqueString(); 34 | const subscriptions = new CompositeDisposable(); 35 | 36 | const job = new Promise((resolve, reject) => { 37 | subscriptions.add(worker.on(`error:${config.id}`, ({message, stack}) => { 38 | const error = new Error(message); 39 | error.stack = stack; 40 | reject(error); 41 | })); 42 | 43 | subscriptions.add(worker.on(config.id, data => { 44 | resolve(data); 45 | })); 46 | 47 | worker.send(config); 48 | }); 49 | 50 | try { 51 | return await job; 52 | } catch (error) { 53 | console.error(error); 54 | atom.notifications.addError( 55 | 'linter-xo:: Error while running XO!', 56 | { 57 | detail: error.message, 58 | dismissable: true 59 | } 60 | ); 61 | } finally { 62 | subscriptions.dispose(); 63 | } 64 | } 65 | 66 | module.exports.lint = function (filename) { 67 | startWorker(); 68 | 69 | return (editorText, options) => limit(() => sendJob({type: 'lint', editorText, filename, options})); 70 | }; 71 | -------------------------------------------------------------------------------- /lib/worker.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const pMapSeries = require('p-map-series'); 3 | const proxyquire = require('proxyquire'); 4 | const {CompositeDisposable} = require('event-kit'); 5 | const {processMessages, MockTask} = require('../mocks/mock-task.js'); 6 | 7 | const stubs = { 8 | atom: { 9 | CompositeDisposable, 10 | '@noCallThru': true, 11 | '@global': true 12 | } 13 | }; 14 | 15 | const {startWorker, stopWorker, lint} = proxyquire('./worker.js', stubs); 16 | 17 | test.afterEach(() => { 18 | stopWorker(); 19 | }); 20 | 21 | test.serial('starting the worker starts a child process', t => { 22 | const task = new MockTask(); 23 | startWorker(() => task); 24 | 25 | t.true(task.childProcess.connected); 26 | }); 27 | 28 | test.serial('stopping the worker terminates the child process', t => { 29 | const task = new MockTask(); 30 | startWorker(() => task); 31 | stopWorker(); 32 | 33 | t.false(task.childProcess.connected); 34 | }); 35 | 36 | test.serial('lint sends a job to the child process', async t => { 37 | const task = new MockTask(); 38 | startWorker(() => task); 39 | const lintJob = lint('foo.js')('// some editor text'); 40 | 41 | processMessages(task); 42 | 43 | const result = await lintJob; 44 | 45 | t.deepEqual(result, { 46 | editorText: '// some editor text', 47 | filename: 'foo.js', 48 | options: undefined, 49 | type: 'lint' 50 | }); 51 | }); 52 | 53 | test.serial('lint can send multiple concurrent jobs to the child process', async t => { 54 | console.log('concurrent test'); 55 | const task = new MockTask(); 56 | startWorker(() => task); 57 | 58 | const lintJobs = pMapSeries([ 59 | lint('foo.js')('// some editor text'), 60 | lint('bar.js')('// some more editor text'), 61 | lint('baz.js')('// some other editor text') 62 | ], job => { 63 | processMessages(task); 64 | return job; 65 | }); 66 | 67 | processMessages(task); 68 | 69 | const [fooResult, barResult, bazResult] = await lintJobs; 70 | 71 | t.deepEqual(fooResult, { 72 | editorText: '// some editor text', 73 | filename: 'foo.js', 74 | options: undefined, 75 | type: 'lint' 76 | }); 77 | 78 | t.deepEqual(barResult, { 79 | editorText: '// some more editor text', 80 | filename: 'bar.js', 81 | options: undefined, 82 | type: 'lint' 83 | }); 84 | 85 | t.deepEqual(bazResult, { 86 | editorText: '// some other editor text', 87 | filename: 'baz.js', 88 | options: undefined, 89 | type: 'lint' 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /lib/xo-helper.js: -------------------------------------------------------------------------------- 1 | /* global emit */ 2 | const lint = require('./lint.js'); 3 | 4 | process.title = 'linter-xo helper'; 5 | 6 | module.exports = function () { 7 | process.on('message', async config => { 8 | const {id, editorText, filename, options} = config; 9 | 10 | try { 11 | const report = await lint(filename)(editorText, options); 12 | 13 | emit(id, report); 14 | } catch (error) { 15 | emit(`error:${id}`, {message: error.message, stack: error.stack}); 16 | } 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /mocks/delegated/disabled/node_modules/xo/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | __test_name: 'delegated/disabled' 3 | }; 4 | -------------------------------------------------------------------------------- /mocks/delegated/disabled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delegated/disabled", 3 | "xo": false, 4 | "devDependencies": { 5 | "xo": "*" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /mocks/delegated/enabled/node_modules/xo/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | __test_name: 'delegated/enabled' 3 | }; 4 | -------------------------------------------------------------------------------- /mocks/delegated/enabled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delegated/enabled", 3 | "devDependencies": { 4 | "xo": "*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /mocks/delegated/node_modules/xo/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | __test_name: 'delegated' 3 | }; 4 | -------------------------------------------------------------------------------- /mocks/delegated/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delegated", 3 | "devDependencies": { 4 | "xo": "*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /mocks/enabled/bad.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | console.log("bar"); 3 | -------------------------------------------------------------------------------- /mocks/enabled/empty.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/atom-linter-xo/aa395154d772fc24da19b6f3a4433abe5be9ff9e/mocks/enabled/empty.js -------------------------------------------------------------------------------- /mocks/enabled/fixable.js: -------------------------------------------------------------------------------- 1 | const foo = "bar" 2 | 3 | 4 | 5 | console.log(foo);;;;;;;;;;;; 6 | 7 | 8 | 9 | 10 | console.log(foo) 11 | -------------------------------------------------------------------------------- /mocks/enabled/good.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | console.log('bar'); 4 | -------------------------------------------------------------------------------- /mocks/enabled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enabled", 3 | "devDependencies": { 4 | "xo": "*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /mocks/enabled/relative-path.js: -------------------------------------------------------------------------------- 1 | const someExport = require('./some-export'); 2 | -------------------------------------------------------------------------------- /mocks/enabled/save-fixable-default.js: -------------------------------------------------------------------------------- 1 | // uncapitalized comment 2 | -------------------------------------------------------------------------------- /mocks/enabled/save-fixable.js: -------------------------------------------------------------------------------- 1 | //Uncapitalized comment 2 | -------------------------------------------------------------------------------- /mocks/enabled/some-export.js: -------------------------------------------------------------------------------- 1 | module.exports = true; 2 | -------------------------------------------------------------------------------- /mocks/example.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var foo = "bar"; 3 | 4 | fn(function (err) {}); 5 | 6 | const foo = true; 7 | -------------------------------------------------------------------------------- /mocks/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const tmp = require('tmp') 3 | const MockEditor = require('./mock-editor') 4 | 5 | module.exports.files = { 6 | bad: getFile('bad'), 7 | empty: getFile('empty'), 8 | fixable: getFile('fixable'), 9 | relativePath: getFile('relative-path'), 10 | saveFixable: getFile('save-fixable'), 11 | saveFixableDefault: getFile('save-fixable-default') 12 | }; 13 | 14 | module.exports.paths = { 15 | disabled: getPath('disabled'), 16 | delegated: getPath('delegated'), 17 | 'delegated/disabled': getPath('delegated/disabled'), 18 | 'delegated/enabled': getPath('delegated/enabled'), 19 | enabled: getPath('enabled'), 20 | local: getPath('local') 21 | }; 22 | 23 | module.exports.editors = { 24 | disabled: withPath('disabled'), 25 | delegated: withPath('delegated'), 26 | enabled: withPath('enabled') 27 | }; 28 | 29 | // (file: string) => string 30 | function getFile(name) { 31 | return require.resolve(path.join(getPath('enabled'), name)); 32 | } 33 | 34 | // (base: string) => string 35 | function getPath(base) { 36 | if (base === 'disabled') { 37 | return path.join(tmp.dirSync().name.toLowerCase()); 38 | } 39 | 40 | return path.join(__dirname, base); 41 | } 42 | 43 | // (base: string) => (text: string) => MockEditor 44 | function withPath(base) { 45 | return text => new MockEditor({text, path: getPath(base)}); 46 | } 47 | -------------------------------------------------------------------------------- /mocks/local/node_modules/xo/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintText: function() {}, 3 | __test_name: 'local' 4 | }; 5 | -------------------------------------------------------------------------------- /mocks/local/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local", 3 | "devDependencies": { 4 | "xo": "*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /mocks/mock-editor.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const TextBuffer = require('text-buffer'); 3 | 4 | const privateSpace = new WeakMap(); 5 | 6 | module.exports = class MockEditor { 7 | // (Object) => Object 8 | constructor(options) { 9 | assert(typeof options.path === 'string', msg('options.path', 'string', options.path)); 10 | assert(typeof options.path === 'string', msg('options.buffer', 'string', options.path)); 11 | 12 | privateSpace.set(this, { 13 | options, 14 | buffer: new TextBuffer({text: options.text}), 15 | path: options.path 16 | }); 17 | } 18 | 19 | getBuffer() { 20 | const {buffer} = privateSpace.get(this); 21 | return buffer; 22 | } 23 | 24 | getPath() { 25 | const {path} = privateSpace.get(this); 26 | return path; 27 | } 28 | 29 | getText() { 30 | const {buffer} = privateSpace.get(this); 31 | return buffer.getText(); 32 | } 33 | }; 34 | 35 | // (name: string, value: any) => string 36 | function msg(name, expected, value) { 37 | return `MockEditor: ${name} must be of type ${expected}, received value "${value}" with value ${typeof value}`; 38 | } 39 | -------------------------------------------------------------------------------- /mocks/mock-task.js: -------------------------------------------------------------------------------- 1 | const {Emitter} = require('event-kit'); 2 | 3 | const privateSpace = new WeakMap(); 4 | 5 | module.exports.processMessages = task => { 6 | const {emitter, messages} = privateSpace.get(task); 7 | 8 | for (const {id, ...message} of messages) { 9 | emitter.emit(id, message); 10 | } 11 | }; 12 | 13 | module.exports.MockTask = class MockTask { 14 | constructor() { 15 | const messages = []; 16 | privateSpace.set(this, { 17 | childProcess: {connected: false}, 18 | emitter: new Emitter(), 19 | messages 20 | }); 21 | } 22 | 23 | get childProcess() { 24 | const {childProcess} = privateSpace.get(this); 25 | return {...childProcess}; 26 | } 27 | 28 | on(event, callback) { 29 | const {emitter} = privateSpace.get(this); 30 | return emitter.on(event, callback); 31 | } 32 | 33 | send(message) { 34 | const {messages} = privateSpace.get(this); 35 | return messages.push(message); 36 | } 37 | 38 | start() { 39 | const {childProcess} = privateSpace.get(this); 40 | childProcess.connected = true; 41 | } 42 | 43 | terminate() { 44 | const {childProcess} = privateSpace.get(this); 45 | childProcess.connected = false; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linter-xo", 3 | "version": "0.31.0", 4 | "description": "Linter for XO", 5 | "license": "MIT", 6 | "repository": "xojs/atom-linter-xo", 7 | "author": { 8 | "name": "Sindre Sorhus", 9 | "email": "sindresorhus@gmail.com", 10 | "url": "sindresorhus.com" 11 | }, 12 | "private": true, 13 | "engines": { 14 | "atom": ">=1.34.0" 15 | }, 16 | "scripts": { 17 | "lint": "xo --ignore='mocks/**/*'", 18 | "pretest": "npm run lint", 19 | "test": "nyc ava" 20 | }, 21 | "keywords": [ 22 | "javascript", 23 | "linter", 24 | "eslint", 25 | "code-style", 26 | "xo" 27 | ], 28 | "dependencies": { 29 | "atom-linter": "^10.0.0", 30 | "atom-package-deps": "^7.0.0", 31 | "eslint-rule-documentation": "^1.0.0", 32 | "load-json-file": "^5.1.0", 33 | "p-limit": "^2.3.0", 34 | "pkg-up": "^3.1.0", 35 | "resolve-from": "^4.0.0", 36 | "unique-string": "^2.0.0", 37 | "xo": "^0.39.0" 38 | }, 39 | "devDependencies": { 40 | "ava": "^3.13.0", 41 | "event-kit": "^2.5.3", 42 | "nyc": "^15.1.0", 43 | "p-map-series": "^2.1.0", 44 | "proxyquire": "^2.0.1", 45 | "text-buffer": "^13.15.2", 46 | "tmp": "0.0.33" 47 | }, 48 | "package-deps": [ 49 | { 50 | "name": "linter", 51 | "minimumVersion": "2.0.0" 52 | } 53 | ], 54 | "providedServices": { 55 | "linter": { 56 | "versions": { 57 | "2.0.0": "provideLinter" 58 | } 59 | } 60 | }, 61 | "nyc": { 62 | "reporter": [ 63 | "text", 64 | "lcov" 65 | ] 66 | }, 67 | "xo": { 68 | "globals": [ 69 | "atom", 70 | "window" 71 | ], 72 | "overrides": [ 73 | { 74 | "files": "spec/**/*", 75 | "envs": [ 76 | "atomtest", 77 | "jasmine" 78 | ] 79 | } 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # linter-xo 2 | 3 | > [Linter](https://github.com/atom-community/linter) for [XO](https://github.com/xojs/xo) 4 | 5 | ![](screenshot.png) 6 | 7 | ## Install 8 | 9 | ``` 10 | $ apm install linter-xo 11 | ``` 12 | 13 | Or, Settings → Install → Search for `linter-xo`. 14 | 15 | ## Usage 16 | 17 | Just write some code. 18 | 19 | Settings can be found in the `Linter` package settings. XO [config](https://github.com/xojs/xo#config) should be defined in package.json. 20 | 21 | **Note that it will only lint when XO is a dependency/devDependency in package.json.**\ 22 | This is to ensure it doesn't activate and conflict on projects using another linter, like ESLint.\ 23 | [We're considering a way to manually enable XO.](https://github.com/xojs/atom-linter-xo/issues/21) 24 | 25 | ### Fix 26 | 27 | Automagically fix many of the linter issues by running `XO: Fix` in the Command Palette. 28 | 29 | #### Fix on save 30 | 31 | You can also have it fix the code when you save the file. *(Only when XO is used in the project)* 32 | 33 | Enable it by going to; Settings → Packages → linter-xo → Settings, and then checking `Fix On Save`. 34 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/atom-linter-xo/aa395154d772fc24da19b6f3a4433abe5be9ff9e/screenshot.png -------------------------------------------------------------------------------- /spec/linter-xo-spec.js: -------------------------------------------------------------------------------- 1 | const {files} = require('../mocks/index.js'); 2 | const {provideLinter} = require('..'); 3 | 4 | describe('xo provider for linter', () => { 5 | const {lint} = provideLinter(); 6 | const cmd = (editor, name) => atom.commands.dispatch(atom.views.getView(editor), name); 7 | 8 | const fix = async editor => { 9 | cmd(editor, 'XO:Fix'); 10 | 11 | return new Promise(resolve => { 12 | editor.onDidChange(() => { 13 | resolve(editor); 14 | }); 15 | }); 16 | }; 17 | 18 | beforeEach(async () => { 19 | await atom.packages.activatePackage('language-javascript'); 20 | await atom.packages.activatePackage('linter-xo'); 21 | }); 22 | 23 | describe('checks bad.js and', () => { 24 | it('finds at least one message', async () => { 25 | const editor = await atom.workspace.open(files.bad); 26 | const messages = await lint(editor); 27 | expect(messages.length).toBeGreaterThan(0); 28 | }); 29 | 30 | it('produces expected message', async () => { 31 | const editor = await atom.workspace.open(files.bad); 32 | const [message] = await lint(editor); 33 | expect(message.location.file).toEqual(files.bad); 34 | expect(message.severity).toEqual('error'); 35 | expect(message.excerpt).toEqual('Strings must use singlequote.'); 36 | expect(message.url).toEqual('https://eslint.org/docs/rules/quotes'); 37 | 38 | expect(message.location.position).toEqual([[1, 12], [1, 17]]); 39 | expect(message.solutions.length).toBe(1); 40 | expect(message.solutions[0].position).toEqual({start: {row: 1, column: 12}, end: {row: 1, column: 17}}); 41 | expect(message.solutions[0].replaceWith).toBe('\'bar\''); 42 | }); 43 | }); 44 | 45 | describe('checks empty.js and', () => { 46 | it('finds no message', async () => { 47 | const editor = await atom.workspace.open(files.empty); 48 | const messages = await lint(editor); 49 | expect(messages.length).toBe(0); 50 | }); 51 | }); 52 | 53 | describe('checks good.js and', () => { 54 | it('finds no message', async () => { 55 | const editor = await atom.workspace.open(files.empty); 56 | const messages = await lint(editor); 57 | expect(messages.length).toBe(0); 58 | }); 59 | }); 60 | 61 | describe('checks relative-path.js and', () => { 62 | it('shows no error notifications', async () => { 63 | const editor = await atom.workspace.open(files.relativePath); 64 | await lint(editor); 65 | const errorNotifications = atom.notifications.getNotifications().filter(notification => notification.getType() === 'error'); 66 | expect(errorNotifications.length).toBe(0); 67 | }); 68 | }); 69 | 70 | describe('fixes fixable.js and', () => { 71 | it('produces text without errors', async () => { 72 | const expected = 'const foo = \'bar\';\n\nconsole.log(foo);\n\nconsole.log(foo);\n'; 73 | const editor = await atom.workspace.open(files.fixable); 74 | const fixed = await fix(editor); 75 | const actual = fixed.getText(); 76 | expect(actual).toBe(expected); 77 | 78 | const messages = await lint(fixed); 79 | expect(messages.length).toBe(0); 80 | }); 81 | 82 | it('exclude default rules configured in rulesToDisableWhileFixingOnSave', async () => { 83 | atom.config.set('linter-xo.fixOnSave', true); 84 | const expected = '// uncapitalized comment\n'; 85 | const editor = await atom.workspace.open(files.saveFixableDefault); 86 | await editor.save(); 87 | 88 | const actual = editor.getText(); 89 | expect(actual).toBe(expected); 90 | 91 | const messages = await lint(editor); 92 | expect(messages.length).toBe(1); 93 | }); 94 | 95 | it('exclude rules configured in rulesToDisableWhileFixingOnSave', async () => { 96 | atom.config.set('linter-xo.fixOnSave', true); 97 | atom.config.set('linter-xo.rulesToDisableWhileFixingOnSave', ['spaced-comment']); 98 | const expected = '//Uncapitalized comment\n'; 99 | const editor = await atom.workspace.open(files.saveFixable); 100 | await editor.save(); 101 | 102 | const actual = editor.getText(); 103 | expect(actual).toBe(expected); 104 | 105 | const messages = await lint(editor); 106 | expect(messages.length).toBe(1); 107 | }); 108 | 109 | it('retains cursor position', async () => { 110 | const editor = await atom.workspace.open(files.fixable); 111 | editor.setCursorBufferPosition([5, 0]); 112 | const fixed = await fix(editor); 113 | const {row: actual} = fixed.getCursorBufferPosition(); 114 | expect(actual).toBe(5); 115 | }); 116 | }); 117 | }); 118 | --------------------------------------------------------------------------------