diff --git a/lib/main.js b/lib/main.js index 1750f450..35b9d979 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,55 +1,31 @@ 'use babel'; // eslint-disable-next-line import/extensions, import/no-extraneous-dependencies -import { CompositeDisposable } from 'atom'; +import { CompositeDisposable, Task } from 'atom'; import path from 'path'; import fs from 'fs'; +import * as workerHelper from './workerHelper'; -const TSLINT_MODULE_NAME = 'tslint'; const grammarScopes = ['source.ts', 'source.tsx']; const editorClass = 'linter-tslint-compatible-editor'; -const tslintCache = new Map(); -let tslintDef; -let requireResolve; const idleCallbacks = new Set(); +const config = { + rulesDirectory: null, + useLocalTslint: false, +}; -/** - * Shim for TSLint v3 interoperability - * @param {Function} Linter TSLint v3 linter - * @return {Function} TSLint v4-compatible linter - */ -function shim(Linter) { - function LinterShim(options) { - this.options = options; - this.results = {}; - } - - // Assign class properties - Object.assign(LinterShim, Linter); - - // Assign instance methods - LinterShim.prototype = Object.assign({}, Linter.prototype, { - lint(filePath, text, configuration) { - const options = Object.assign({}, this.options, { configuration }); - const linter = new Linter(filePath, text, options); - this.results = linter.lint(); - }, - getResult() { - return this.results; - }, +// Worker still hasn't initialized, since the queued idle callbacks are +// done in order, waiting on a newly queued idle callback will ensure that +// the worker has been initialized +const waitOnIdle = async () => + new Promise((resolve) => { + const callbackID = window.requestIdleCallback(() => { + idleCallbacks.delete(callbackID); + resolve(); + }); + idleCallbacks.add(callbackID); }); - return LinterShim; -} - -function loadDefaultTSLint() { - if (!tslintDef) { - tslintDef = require('loophole').allowUnsafeNewFunction(() => - // eslint-disable-next-line import/no-dynamic-require - require(TSLINT_MODULE_NAME).Linter); - } -} - export default { activate() { let depsCallbackID; @@ -57,8 +33,6 @@ export default { idleCallbacks.delete(depsCallbackID); // Install package dependencies require('atom-package-deps').install('linter-tslint'); - // Initialize the default TSLint instance - loadDefaultTSLint(); }; depsCallbackID = window.requestIdleCallback(lintertslintDeps); idleCallbacks.add(depsCallbackID); @@ -71,14 +45,15 @@ export default { if (dir && path.isAbsolute(dir)) { fs.stat(dir, (err, stats) => { if (stats && stats.isDirectory()) { - this.rulesDirectory = dir; + config.rulesDirectory = dir; + workerHelper.changeConfig('rulesDirectory', dir); } }); } }), atom.config.observe('linter-tslint.useLocalTslint', (use) => { - tslintCache.clear(); - this.useLocalTslint = use; + config.useLocalTslint = use; + workerHelper.changeConfig('useLocalTslint', use); }), atom.config.observe('linter-tslint.ignoreTypings', (ignoreTypings) => { this.ignoreTypings = ignoreTypings; @@ -120,9 +95,7 @@ export default { const cursorPosition = textEditor.getCursorBufferPosition(); try { - const results = await this.lint(textEditor, { - fix: true, - }); + const results = await workerHelper.requestJob('fix', textEditor); const notificationText = results && results.length === 0 ? 'Linter-TSLint: Fix complete.' : @@ -138,55 +111,21 @@ export default { }, }), ); + + const createWorkerCallback = window.requestIdleCallback(() => { + this.worker = new Task(require.resolve('./worker.js')); + idleCallbacks.delete(createWorkerCallback); + }); + idleCallbacks.add(createWorkerCallback); }, deactivate() { idleCallbacks.forEach(callbackID => window.cancelIdleCallback(callbackID)); idleCallbacks.clear(); this.subscriptions.dispose(); - }, - - async getLinter(filePath) { - const basedir = path.dirname(filePath); - if (tslintCache.has(basedir)) { - return tslintCache.get(basedir); - } - - // Initialize the default instance if it hasn't already been initialized - loadDefaultTSLint(); - - if (this.useLocalTslint) { - return this.getLocalLinter(basedir); - } - tslintCache.set(basedir, tslintDef); - return tslintDef; - }, - - async getLocalLinter(basedir) { - return new Promise((resolve) => { - if (!requireResolve) { - requireResolve = require('resolve'); - } - requireResolve(TSLINT_MODULE_NAME, { basedir }, - (err, linterPath, pkg) => { - let linter; - if (!err && pkg && /^3|4|5\./.test(pkg.version)) { - if (pkg.version.startsWith('3')) { - // eslint-disable-next-line import/no-dynamic-require - linter = shim(require('loophole').allowUnsafeNewFunction(() => require(linterPath))); - } else { - // eslint-disable-next-line import/no-dynamic-require - linter = require('loophole').allowUnsafeNewFunction(() => require(linterPath).Linter); - } - } else { - linter = tslintDef; - } - tslintCache.set(basedir, linter); - return resolve(linter); - }, - ); - }); + workerHelper.terminateWorker(); + this.worker = null; }, provideLinter() { @@ -200,77 +139,22 @@ export default { return []; } - return this.lint(textEditor); - }, - }; - }, - - async lint(textEditor, options) { - const filePath = textEditor.getPath(); - - if (filePath === null || filePath === undefined) { - return null; - } - - const text = textEditor.getText(); - const Linter = await this.getLinter(filePath); - const configurationPath = Linter.findConfigurationPath(null, filePath); - const configuration = Linter.loadConfigurationFromPath(configurationPath); - - let { rulesDirectory } = configuration; - if (rulesDirectory) { - const configurationDir = path.dirname(configurationPath); - if (!Array.isArray(rulesDirectory)) { - rulesDirectory = [rulesDirectory]; - } - rulesDirectory = rulesDirectory.map((dir) => { - if (path.isAbsolute(dir)) { - return dir; + if (!this.worker) { + await waitOnIdle(); } - return path.join(configurationDir, dir); - }); - - if (this.rulesDirectory) { - rulesDirectory.push(this.rulesDirectory); - } - } - const linter = new Linter(Object.assign({ - formatter: 'json', - rulesDirectory, - }, options)); + workerHelper.startWorker(this.worker, config); - linter.lint(filePath, text, configuration); - const lintResult = linter.getResult(); + const text = textEditor.getText(); + const results = await workerHelper.requestJob('lint', textEditor); - if (textEditor.getText() !== text) { - // Text has been modified since the lint was triggered, tell linter not to update - return null; - } - - if ( - // tslint@<5 - !lintResult.failureCount && - // tslint@>=5 - !lintResult.errorCount && - !lintResult.warningCount && - !lintResult.infoCount - ) { - return []; - } + if (textEditor.getText() !== text) { + // Text has been modified since the lint was triggered, tell linter not to update + return null; + } - return lintResult.failures.map((failure) => { - const startPosition = failure.getStartPosition().getLineAndCharacter(); - const endPosition = failure.getEndPosition().getLineAndCharacter(); - return { - type: failure.ruleSeverity || 'warning', - text: `${failure.getRuleName()} - ${failure.getFailure()}`, - filePath: path.normalize(failure.getFileName()), - range: [ - [startPosition.line, startPosition.character], - [endPosition.line, endPosition.character], - ], - }; - }); + return results; + }, + }; }, }; diff --git a/lib/worker.js b/lib/worker.js new file mode 100644 index 00000000..3061832d --- /dev/null +++ b/lib/worker.js @@ -0,0 +1,183 @@ +'use babel'; + +/* global emit */ + +import path from 'path'; + +process.title = 'linter-tslint worker'; + +const tslintModuleName = 'tslint'; +const tslintCache = new Map(); +const config = { + useLocalTslint: false, +}; + +let tslintDef; +let requireResolve; + +/** + * Shim for TSLint v3 interoperability + * @param {Function} Linter TSLint v3 linter + * @return {Function} TSLint v4-compatible linter + */ +function shim(Linter) { + function LinterShim(options) { + this.options = options; + this.results = {}; + } + + // Assign class properties + Object.assign(LinterShim, Linter); + + // Assign instance methods + LinterShim.prototype = Object.assign({}, Linter.prototype, { + lint(filePath, text, configuration) { + const options = Object.assign({}, this.options, { configuration }); + const linter = new Linter(filePath, text, options); + this.results = linter.lint(); + }, + getResult() { + return this.results; + }, + }); + + return LinterShim; +} + +function loadDefaultTSLint() { + if (!tslintDef) { + // eslint-disable-next-line import/no-dynamic-require + tslintDef = require(tslintModuleName).Linter; + } +} + +async function getLocalLinter(basedir) { + return new Promise((resolve) => { + if (!requireResolve) { + requireResolve = require('resolve'); + } + requireResolve(tslintModuleName, { basedir }, + (err, linterPath, pkg) => { + let linter; + if (!err && pkg && /^3|4|5\./.test(pkg.version)) { + if (pkg.version.startsWith('3')) { + // eslint-disable-next-line import/no-dynamic-require + linter = shim(require('loophole').allowUnsafeNewFunction(() => require(linterPath))); + } else { + // eslint-disable-next-line import/no-dynamic-require + linter = require('loophole').allowUnsafeNewFunction(() => require(linterPath).Linter); + } + } else { + linter = tslintDef; + } + tslintCache.set(basedir, linter); + return resolve(linter); + }, + ); + }); +} + +async function getLinter(filePath) { + const basedir = path.dirname(filePath); + if (tslintCache.has(basedir)) { + return tslintCache.get(basedir); + } + + // Initialize the default instance if it hasn't already been initialized + loadDefaultTSLint(); + + if (config.useLocalTslint) { + return getLocalLinter(basedir); + } + + tslintCache.set(basedir, tslintDef); + return tslintDef; +} + +/** + * Lint the provided TypeScript content + * @param content {string} The content of the TypeScript file + * @param filePath {string} File path of the TypeScript filePath + * @param options {Object} Linter options + * @return Array of lint results + */ +async function lint(content, filePath, options) { + if (filePath === null || filePath === undefined) { + return null; + } + + const Linter = await getLinter(filePath); + const configurationPath = Linter.findConfigurationPath(null, filePath); + const configuration = Linter.loadConfigurationFromPath(configurationPath); + + let { rulesDirectory } = configuration; + if (rulesDirectory) { + const configurationDir = path.dirname(configurationPath); + if (!Array.isArray(rulesDirectory)) { + rulesDirectory = [rulesDirectory]; + } + rulesDirectory = rulesDirectory.map((dir) => { + if (path.isAbsolute(dir)) { + return dir; + } + return path.join(configurationDir, dir); + }); + + if (rulesDirectory) { + rulesDirectory.push(rulesDirectory); + } + } + + const linter = new Linter(Object.assign({ + formatter: 'json', + rulesDirectory, + }, options)); + + linter.lint(filePath, content, configuration); + const lintResult = linter.getResult(); + + if ( + // tslint@<5 + !lintResult.failureCount && + // tslint@>=5 + !lintResult.errorCount && + !lintResult.warningCount && + !lintResult.infoCount + ) { + return []; + } + + return lintResult.failures.map((failure) => { + const startPosition = failure.getStartPosition().getLineAndCharacter(); + const endPosition = failure.getEndPosition().getLineAndCharacter(); + return { + type: failure.ruleSeverity || 'warning', + text: `${failure.getRuleName()} - ${failure.getFailure()}`, + filePath: path.normalize(failure.getFileName()), + range: [ + [startPosition.line, startPosition.character], + [endPosition.line, endPosition.character], + ], + }; + }); +} + +export default async function (initialConfig) { + config.useLocalTslint = initialConfig.useLocalTslint; + + process.on('message', async (message) => { + if (message.messageType === 'config') { + config[message.message.key] = message.message.value; + + if (message.message.key === 'useLocalTslint') { + tslintCache.clear(); + } + } else { + const { emitKey, jobType, content, filePath } = message.message; + const options = jobType === 'fix' ? { fix: true } : {}; + + const results = await lint(content, filePath, options); + emit(emitKey, results); + } + }); +} diff --git a/lib/workerHelper.js b/lib/workerHelper.js new file mode 100644 index 00000000..7de60b48 --- /dev/null +++ b/lib/workerHelper.js @@ -0,0 +1,62 @@ +'use babel'; + +import cryptoRandomString from 'crypto-random-string'; + +let workerInstance; + +export function startWorker(worker, config) { + if (workerInstance !== worker) { + workerInstance = worker; + workerInstance.start(config); + } +} + +export function terminateWorker() { + if (workerInstance) { + workerInstance.terminate(); + workerInstance = null; + } +} + +export function changeConfig(key, value) { + if (workerInstance) { + workerInstance.send({ + messageType: 'config', + message: { key, value }, + }); + } +} + +export function requestJob(jobType, textEditor) { + const emitKey = cryptoRandomString(10); + + return new Promise((resolve, reject) => { + const errSub = workerInstance.on('task:error', (...err) => { + // Re-throw errors from the task + const error = new Error(err[0]); + // Set the stack to the one given to us by the worker + error.stack = err[1]; + reject(error); + }); + + const responseSub = workerInstance.on(emitKey, (data) => { + errSub.dispose(); + responseSub.dispose(); + resolve(data); + }); + + try { + workerInstance.send({ + messageType: 'job', + message: { + emitKey, + jobType, + content: textEditor.getText(), + filePath: textEditor.getPath(), + }, + }); + } catch (e) { + reject(e); + } + }); +} diff --git a/package.json b/package.json index 195537f1..dd8e9824 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "atom-package-deps": "^4.3.1", + "crypto-random-string": "^1.0.0", "loophole": "^1.1.0", "resolve": "^1.2.0", "tslint": "5.1.0",