diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..49e1ec06d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,148 @@ +# Change Log +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/). +This change log adheres to standards from [Keep a CHANGELOG](http://keepachangelog.com). + +## [Unreleased] +Major perf improvements. Between parsing only once and ignoring gigantic, non-module `node_modules`, +there is very little added time. + +My test project takes 17s to lint completely, down from 55s, when using the +memoizing parser, and takes only 27s with naked `babel-eslint` (thus, reparsing local modules). + +### Added +- This change log ([#216]) +- Experimental memoizing [parser](./memo-parser/README.md) + +### Fixed +- Huge reduction in execution time by _only_ ignoring [`import/ignore` setting] if + something that looks like an `export` is detected in the module content. + +## [1.2.0] - 2016-03-19 +Thanks @lencioni for identifying a huge amount of rework in resolve and kicking +off a bunch of memoization. + +I'm seeing 62% improvement over my normal test codebase when executing only +[`no-unresolved`] in isolation, and ~35% total reduction in lint time. + +### Changed +- added caching to core/resolve via [#214], configured via [`import/cache` setting] + +## [1.1.0] - 2016-03-15 +### Added +- Added an [`ignore`](./docs/rules/no-unresolved.md#ignore) option to [`no-unresolved`] for those pesky files that no +resolver can find. (still prefer enhancing the Webpack and Node resolvers to +using it, though). See [#89] for details. + +## [1.0.4] - 2016-03-11 +### Changed +- respect hoisting for deep namespaces ([`namespace`]/[`no-deprecated`]) ([#211]) + +### Fixed +- don't crash on self references ([#210]) +- correct cache behavior in `eslint_d` for deep namespaces (#200) + +## [1.0.3] - 2016-02-26 +### Changed +- no-deprecated follows deep namespaces ([#191]) + +### Fixed +- [`namespace`] no longer flags modules with only a default export as having no +names. (ns.default is valid ES6) + +## [1.0.2] - 2016-02-26 +### Fixed +- don't parse imports with no specifiers ([#192]) + +## [1.0.1] - 2016-02-25 +### Fixed +- export `stage-0` shared config +- documented [`no-deprecated`] +- deep namespaces are traversed regardless of how they get imported ([#189]) + +## [1.0.0] - 2016-02-24 +### Added +- [`no-deprecated`]: WIP rule to let you know at lint time if you're using +deprecated functions, constants, classes, or modules. + +### Changed +- [`namespace`]: support deep namespaces ([#119] via [#157]) + +## [1.0.0-beta.0] - 2016-02-13 +### Changed +- support for (only) ESLint 2.x +- no longer needs/refers to `import/parser` or `import/parse-options`. Instead, +ESLint provides the configured parser + options to the rules, and they use that +to parse dependencies. + +### Removed +- `babylon` as default import parser (see Breaking) + +## [0.13.0] - 2016-02-08 +### Added +- [`no-commonjs`] rule +- [`no-amd`] rule + +### Removed +- Removed vestigial `no-require` rule. [`no-commonjs`] is more complete. + +## [0.12.2] - 2016-02-06 [YANKED] +Unpublished from npm and re-released as 0.13.0. See [#170]. + +## [0.12.1] - 2015-12-17 +### Changed +- Broke docs for rules out into individual files. + +## [0.12.0] - 2015-12-14 +### Changed +- Ignore [`import/ignore` setting] if exports are actually found in the parsed module. Does +this to support use of `jsnext:main` in `node_modules` without the pain of +managing a whitelist or a nuanced blacklist. + +## [0.11.0] - 2015-11-27 +### Added +- Resolver plugins. Now the linter can read Webpack config, properly follow +aliases and ignore externals, dismisses inline loaders, etc. etc.! + +## Earlier releases (0.10.1 and younger) +See [GitHub release notes](https://github.com/benmosher/eslint-plugin-import/releases?after=v0.11.0) +for info on changes for earlier releases. + + +[`import/cache` setting]: ./README.md#importcache +[`import/ignore` setting]: ./README.md#importignore + +[`no-unresolved`]: ./docs/rules/no-unresolved.md +[`no-deprecated`]: ./docs/rules/no-deprecated.md +[`no-commonjs`]: ./docs/rules/no-commonjs.md +[`no-amd`]: ./docs/rules/no-amd.md +[`namespace`]: ./docs/rules/namespace.md + +[#211]: https://github.com/benmosher/eslint-plugin-import/pull/211 +[#157]: https://github.com/benmosher/eslint-plugin-import/pull/157 + +[#216]: https://github.com/benmosher/eslint-plugin-import/issues/216 +[#214]: https://github.com/benmosher/eslint-plugin-import/issues/214 +[#210]: https://github.com/benmosher/eslint-plugin-import/issues/210 +[#200]: https://github.com/benmosher/eslint-plugin-import/issues/200 +[#192]: https://github.com/benmosher/eslint-plugin-import/issues/192 +[#191]: https://github.com/benmosher/eslint-plugin-import/issues/191 +[#189]: https://github.com/benmosher/eslint-plugin-import/issues/189 +[#170]: https://github.com/benmosher/eslint-plugin-import/issues/170 +[#119]: https://github.com/benmosher/eslint-plugin-import/issues/119 +[#89]: https://github.com/benmosher/eslint-plugin-import/issues/89 + +[Unreleased]: https://github.com/benmosher/eslint-plugin-import/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/benmosher/eslint-plugin-import/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.4...v1.1.0 +[1.0.4]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/benmosher/eslint-plugin-import/compare/v1.0.0-beta.0...v1.0.0 +[1.0.0-beta.0]: https://github.com/benmosher/eslint-plugin-import/compare/v0.13.0...v1.0.0-beta.0 +[0.13.0]: https://github.com/benmosher/eslint-plugin-import/compare/v0.12.1...v0.13.0 +[0.12.2]: https://github.com/benmosher/eslint-plugin-import/compare/v0.12.1...v0.12.2 +[0.12.1]: https://github.com/benmosher/eslint-plugin-import/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/benmosher/eslint-plugin-import/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/benmosher/eslint-plugin-import/compare/v0.10.1...v0.11.0 diff --git a/memo-parser/README.md b/memo-parser/README.md new file mode 100644 index 0000000000..545ad999a5 --- /dev/null +++ b/memo-parser/README.md @@ -0,0 +1,15 @@ +# eslint-plugin-import/memo-parser + +This parser is just a memoizing wrapper around some actual parser. + +To configure, just add your _actual_ parser to the `parserOptions`, like so: + +```yaml +parser: eslint-plugin-import/memo-parser +# parser: babel-eslint + +parserOptions: + parser: babel-eslint + sourceType: module + ecmaVersion: 6 +``` diff --git a/memo-parser/index.js b/memo-parser/index.js new file mode 100644 index 0000000000..6d6d3cb089 --- /dev/null +++ b/memo-parser/index.js @@ -0,0 +1,38 @@ +"use strict" + +const crypto = require('crypto') + , moduleRequire = require('../lib/core/module-require').default + , hashObject = require('../lib/core/hash').hashObject + +const cache = new Map() + +// must match ESLint default options or we'll miss the cache every time +const parserOptions = { + loc: true, + range: true, + raw: true, + tokens: true, + comment: true, + attachComment: true, +} + +exports.parse = function parse(content, options) { + // them defaults yo + options = Object.assign({}, options, parserOptions) + + const keyHash = crypto.createHash('sha256') + keyHash.update(content) + hashObject(keyHash, options) + + const key = keyHash.digest('hex') + + let ast = cache.get(key) + if (ast != null) return ast + + const realParser = moduleRequire(options.parser) + + ast = realParser.parse(content, options) + cache.set(key, ast) + + return ast +} diff --git a/package.json b/package.json index 320c38a9f7..f43754f30e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-import", - "version": "1.2.0", + "version": "1.3.0", "description": "Import with sanity.", "main": "lib/index.js", "directories": { diff --git a/src/core/getExports.js b/src/core/getExports.js index 67ba33d02a..98c62a0fa4 100644 --- a/src/core/getExports.js +++ b/src/core/getExports.js @@ -7,8 +7,17 @@ import parse from './parse' import resolve from './resolve' import isIgnored from './ignore' -// map from settings sha1 => path => export map objects -const exportCaches = new Map() +import { hashObject } from './hash' + +const exportCache = new Map() + +/** + * detect exports without a full parse. + * used primarily to ignore the import/ignore setting, iif it looks like + * there might be something there (i.e., jsnext:main is set). + * @type {RegExp} + */ +const hasExports = new RegExp('(^|[\\n;])\\s*export\\s[\\w{*]') export default class ExportMap { constructor(path) { @@ -39,18 +48,15 @@ export default class ExportMap { static for(path, context) { let exportMap - const cacheKey = hashObject({ + const cacheKey = hashObject(createHash('sha256'), { settings: context.settings, parserPath: context.parserPath, parserOptions: context.parserOptions, - }) - let exportCache = exportCaches.get(cacheKey) - if (exportCache === undefined) { - exportCache = new Map() - exportCaches.set(cacheKey, exportCache) - } + path, + }).digest('hex') + + exportMap = exportCache.get(cacheKey) - exportMap = exportCache.get(path) // return cached ignore if (exportMap === null) return null @@ -63,30 +69,31 @@ export default class ExportMap { // future: check content equality? } - exportMap = ExportMap.parse(path, context) - exportMap.mtime = stats.mtime + const content = fs.readFileSync(path, { encoding: 'utf8' }) - // ignore empties, optionally - if (exportMap.namespace.size === 0 && isIgnored(path, context)) { - exportMap = null + // check for and cache ignore + if (isIgnored(path, context) && !hasExports.test(content)) { + exportCache.set(cacheKey, null) + return null } - exportCache.set(path, exportMap) + exportMap = ExportMap.parse(path, content, context) + exportMap.mtime = stats.mtime + exportCache.set(cacheKey, exportMap) return exportMap } - static parse(path, context) { + static parse(path, content, context) { var m = new ExportMap(path) try { - var ast = parse(path, context) + var ast = parse(content, context) } catch (err) { m.errors.push(err) return m // can't continue } - // attempt to collect module doc ast.comments.some(c => { if (c.type !== 'Block') return false @@ -338,9 +345,3 @@ export function recursivePatternCapture(pattern, callback) { break } } - -function hashObject(object) { - const settingsShasum = createHash('sha1') - settingsShasum.update(JSON.stringify(object)) - return settingsShasum.digest('hex') -} diff --git a/src/core/hash.js b/src/core/hash.js new file mode 100644 index 0000000000..49e406b358 --- /dev/null +++ b/src/core/hash.js @@ -0,0 +1,42 @@ +/** + * utilities for hashing config objects. + * basically iteratively updates hash with a JSON-like format + */ + +const stringify = JSON.stringify + +export default function hashify(hash, value) { + if (value instanceof Array) { + hashArray(hash, value) + } else if (value instanceof Object) { + hashObject(hash, value) + } else { + hash.update(stringify(value) || 'undefined') + } + + return hash +} + +export function hashArray(hash, array) { + hash.update('[') + for (let i = 0; i < array.length; i++) { + hashify(hash, array[i]) + hash.update(',') + } + hash.update(']') + + return hash +} + +export function hashObject(hash, object) { + hash.update("{") + Object.keys(object).sort().forEach(key => { + hash.update(stringify(key)) + hash.update(':') + hashify(hash, object[key]) + hash.update(",") + }) + hash.update("}") + + return hash +} diff --git a/src/core/module-require.js b/src/core/module-require.js new file mode 100644 index 0000000000..c940c7ae46 --- /dev/null +++ b/src/core/module-require.js @@ -0,0 +1,27 @@ +import Module from 'module' +import * as path from 'path' + +// borrowed from babel-eslint +function createModule(filename) { + var mod = new Module(filename) + mod.filename = filename + mod.paths = Module._nodeModulePaths(path.dirname(filename)) + return mod +} + +export default function moduleRequire(p) { + try { + // attempt to get espree relative to eslint + const eslintPath = require.resolve('eslint') + const eslintModule = createModule(eslintPath) + return require(Module._resolveFilename(p, eslintModule)) + } catch(err) { /* ignore */ } + + try { + // try relative to entry point + return require.main.require(p) + } catch(err) { /* ignore */ } + + // finally, try from here + return require(p) +} diff --git a/src/core/parse.js b/src/core/parse.js index ef7d12903f..99d280f3f0 100644 --- a/src/core/parse.js +++ b/src/core/parse.js @@ -1,6 +1,6 @@ -import fs from 'fs' +import moduleRequire from './module-require' -export default function (p, context) { +export default function (content, context) { if (context == null) throw new Error("need context to parse properly") @@ -16,37 +16,7 @@ export default function (p, context) { parserOptions.attachComment = true // require the parser relative to the main module (i.e., ESLint) - const parser = requireParser(parserPath) + const parser = moduleRequire(parserPath) - return parser.parse( - fs.readFileSync(p, {encoding: 'utf8'}), - parserOptions) -} - -import Module from 'module' -import * as path from 'path' - -// borrowed from babel-eslint -function createModule(filename) { - var mod = new Module(filename) - mod.filename = filename - mod.paths = Module._nodeModulePaths(path.dirname(filename)) - return mod -} - -function requireParser(p) { - try { - // attempt to get espree relative to eslint - const eslintPath = require.resolve('eslint') - const eslintModule = createModule(eslintPath) - return require(Module._resolveFilename(p, eslintModule)) - } catch(err) { /* ignore */ } - - try { - // try relative to entry point - return require.main.require(p) - } catch(err) { /* ignore */ } - - // finally, try from here - return require(p) + return parser.parse(content, parserOptions) } diff --git a/tests/src/core/getExports.js b/tests/src/core/getExports.js index a6c32e3649..c36c5fff16 100644 --- a/tests/src/core/getExports.js +++ b/tests/src/core/getExports.js @@ -77,8 +77,11 @@ describe('getExports', function () { }) it('finds exports for an ES7 module with babel-eslint', function () { + const path = getFilename('jsx/FooES7.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) var imports = ExportMap.parse( - getFilename('jsx/FooES7.js'), + path, + contents, { parserPath: 'babel-eslint' } ) @@ -93,8 +96,9 @@ describe('getExports', function () { context('deprecated imports', function () { let imports before('parse file', function () { - imports = ExportMap.parse( - getFilename('deprecated.js'), parseContext) + const path = getFilename('deprecated.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + imports = ExportMap.parse(path, contents, parseContext) // sanity checks expect(imports.errors).to.be.empty @@ -161,8 +165,9 @@ describe('getExports', function () { context('full module', function () { let imports before('parse file', function () { - imports = ExportMap.parse( - getFilename('deprecated-file.js'), parseContext) + const path = getFilename('deprecated-file.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + imports = ExportMap.parse(path, contents, parseContext) // sanity checks expect(imports.errors).to.be.empty @@ -200,21 +205,27 @@ describe('getExports', function () { const babelContext = { parserPath: 'babel-eslint', parserOptions: { sourceType: 'module' }, settings: {} } it('works with espree & traditional namespace exports', function () { - const a = ExportMap.parse(getFilename('deep/a.js'), espreeContext) + const path = getFilename('deep/a.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + const a = ExportMap.parse(path, contents, espreeContext) expect(a.errors).to.be.empty expect(a.get('b').namespace).to.exist expect(a.get('b').namespace.has('c')).to.be.true }) it('captures namespace exported as default', function () { - const def = ExportMap.parse(getFilename('deep/default.js'), espreeContext) + const path = getFilename('deep/default.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + const def = ExportMap.parse(path, contents, espreeContext) expect(def.errors).to.be.empty expect(def.get('default').namespace).to.exist expect(def.get('default').namespace.has('c')).to.be.true }) it('works with babel-eslint & ES7 namespace exports', function () { - const a = ExportMap.parse(getFilename('deep-es7/a.js'), babelContext) + const path = getFilename('deep-es7/a.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + const a = ExportMap.parse(path, contents, babelContext) expect(a.errors).to.be.empty expect(a.get('b').namespace).to.exist expect(a.get('b').namespace.has('c')).to.be.true @@ -229,7 +240,9 @@ describe('getExports', function () { fs.writeFileSync(getFilename('deep/cache-2.js'), fs.readFileSync(getFilename('deep/cache-2a.js'))) - a = ExportMap.parse(getFilename('deep/cache-1.js'), espreeContext) + const path = getFilename('deep/cache-1.js') + , contents = fs.readFileSync(path, { encoding: 'utf8' }) + a = ExportMap.parse(path, contents, espreeContext) expect(a.errors).to.be.empty expect(a.get('b').namespace).to.exist diff --git a/tests/src/core/parse.js b/tests/src/core/parse.js index a054f393df..0f9ba2f640 100644 --- a/tests/src/core/parse.js +++ b/tests/src/core/parse.js @@ -1,14 +1,21 @@ +import * as fs from 'fs' import { expect } from 'chai' import parse from 'core/parse' import { getFilename } from '../utils' -describe('parse(path, { settings, ecmaFeatures })', function () { +describe('parse(content, { settings, ecmaFeatures })', function () { + let content + + before((done) => + fs.readFile(getFilename('jsx.js'), { encoding: 'utf8' }, + (err, f) => { if (err) { done(err) } else { content = f; done() }})) + it("doesn't support JSX by default", function () { - expect(() => parse(getFilename('jsx.js'), { parserPath: 'espree' })).to.throw(Error) + expect(() => parse(content, { parserPath: 'espree' })).to.throw(Error) }) it('infers jsx from ecmaFeatures when using stock parser', function () { - expect(() => parse(getFilename('jsx.js'), { parserPath: 'espree', parserOptions: { sourceType: 'module', ecmaFeatures: { jsx: true } } })) + expect(() => parse(content, { parserPath: 'espree', parserOptions: { sourceType: 'module', ecmaFeatures: { jsx: true } } })) .not.to.throw(Error) }) })