Skip to content

Commit

Permalink
no-unused-modules: support dynamic imports
Browse files Browse the repository at this point in the history
All occurences of `import('...')` are treated as namespace imports
(`import * as X from '...'`)
  • Loading branch information
maxkomarychev committed Mar 11, 2020
1 parent efd6be1 commit 533df7f
Show file tree
Hide file tree
Showing 12 changed files with 214 additions and 23 deletions.
4 changes: 2 additions & 2 deletions docs/rules/no-unused-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Reports:
- modules without any exports
- individual exports not being statically `import`ed or `require`ed from other modules in the same project
- individual exports not `import`ed or `require`ed from other modules in the same project
- dynamic imports are supported if argument is a literal string

Note: dynamic imports are currently not supported.

## Rule Details

Expand Down
36 changes: 33 additions & 3 deletions src/ExportMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import debug from 'debug'
import { SourceCode } from 'eslint'

import parse from 'eslint-module-utils/parse'
import visit from 'eslint-module-utils/visit'
import resolve from 'eslint-module-utils/resolve'
import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'

Expand Down Expand Up @@ -344,14 +345,43 @@ ExportMap.parse = function (path, content, context) {
var m = new ExportMap(path)

try {
var ast = parse(path, content, context)
var { ast, visitorKeys } = parse(path, content, context)
} catch (err) {
log('parse error:', path, err)
m.errors.push(err)
return m // can't continue
}

if (!unambiguous.isModule(ast)) return null
let hasDynamicImports = false

visit(ast, visitorKeys, {
CallExpression(node) {
if (node.callee.type === 'Import') {
hasDynamicImports = true
const firstArgument = node.arguments[0]
if (firstArgument.type !== 'Literal') {
return null
}
const p = remotePath(firstArgument.value)
if (p == null) {
return null
}
const importedSpecifiers = new Set()
importedSpecifiers.add('ImportNamespaceSpecifier')
const getter = thunkFor(p, context)
m.imports.set(p, {
getter,
source: {
// capturing actual node reference holds full AST in memory!
value: firstArgument.value,
loc: firstArgument.loc,
},
importedSpecifiers,
})
}
},
})

if (!unambiguous.isModule(ast) && !hasDynamicImports) return null

const docstyle = (context.settings && context.settings['import/docstyle']) || ['jsdoc']
const docStyleParsers = {}
Expand Down
13 changes: 13 additions & 0 deletions tests/files/no-unused-modules/dynamic-import-js-2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const importPath = './exports-for-dynamic-js';
class A {
method() {
const c = import(importPath)
}
}


class B {
method() {
const c = import('i-do-not-exist')
}
}
5 changes: 5 additions & 0 deletions tests/files/no-unused-modules/dynamic-import-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class A {
method() {
const c = import('./exports-for-dynamic-js')
}
}
5 changes: 5 additions & 0 deletions tests/files/no-unused-modules/exports-for-dynamic-js-2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const a = 10;
export const b = 20;
export const c = 30;
const d = 40;
export default d;
5 changes: 5 additions & 0 deletions tests/files/no-unused-modules/exports-for-dynamic-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const a = 10
export const b = 20
export const c = 30
const d = 40
export default d
6 changes: 6 additions & 0 deletions tests/files/no-unused-modules/typescript/dynamic-import-ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class A {
method() {
const c = import('./exports-for-dynamic-ts')
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const ts_a = 10
export const ts_b = 20
export const ts_c = 30
const ts_d = 40
export default ts_d
79 changes: 68 additions & 11 deletions tests/src/rules/no-unused-modules.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, testFilePath } from '../utils'
import { test, testFilePath } from '../../src/utils'
import jsxConfig from '../../../config/react'
import typescriptConfig from '../../../config/typescript'

Expand Down Expand Up @@ -71,32 +71,39 @@ ruleTester.run('no-unused-modules', rule, {
// tests for exports
ruleTester.run('no-unused-modules', rule, {
valid: [

test({ options: unusedExportsOptions,
code: 'import { o2 } from "./file-o";export default () => 12',
filename: testFilePath('./no-unused-modules/file-a.js')}),
filename: testFilePath('./no-unused-modules/file-a.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'export const b = 2',
filename: testFilePath('./no-unused-modules/file-b.js')}),
filename: testFilePath('./no-unused-modules/file-b.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'const c1 = 3; function c2() { return 3 }; export { c1, c2 }',
filename: testFilePath('./no-unused-modules/file-c.js')}),
filename: testFilePath('./no-unused-modules/file-c.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'export function d() { return 4 }',
filename: testFilePath('./no-unused-modules/file-d.js')}),
filename: testFilePath('./no-unused-modules/file-d.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'export class q { q0() {} }',
filename: testFilePath('./no-unused-modules/file-q.js')}),
filename: testFilePath('./no-unused-modules/file-q.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'const e0 = 5; export { e0 as e }',
filename: testFilePath('./no-unused-modules/file-e.js')}),
filename: testFilePath('./no-unused-modules/file-e.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'const l0 = 5; const l = 10; export { l0 as l1, l }; export default () => {}',
filename: testFilePath('./no-unused-modules/file-l.js')}),
filename: testFilePath('./no-unused-modules/file-l.js'),
parser: require.resolve('babel-eslint')}),
test({ options: unusedExportsOptions,
code: 'const o0 = 0; const o1 = 1; export { o0, o1 as o2 }; export default () => {}',
filename: testFilePath('./no-unused-modules/file-o.js')}),
],
filename: testFilePath('./no-unused-modules/file-o.js'),
parser: require.resolve('babel-eslint')}),
],
invalid: [
test({ options: unusedExportsOptions,
code: `import eslint from 'eslint'
Expand Down Expand Up @@ -164,6 +171,56 @@ ruleTester.run('no-unused-modules', rule, {
],
})

// test for unused exports with `import()`
ruleTester.run('no-unused-modules', rule, {
valid: [
test({ options: unusedExportsOptions,
code: `
export const a = 10
export const b = 20
export const c = 30
const d = 40
export default d
`,
parser: require.resolve('babel-eslint'),
filename: testFilePath('./no-unused-modules/exports-for-dynamic-js.js')}),
],
invalid: [
test({ options: unusedExportsOptions,
code: `
export const a = 10
export const b = 20
export const c = 30
const d = 40
export default d
`,
parser: require.resolve('babel-eslint'),
filename: testFilePath('./no-unused-modules/exports-for-dynamic-js-2.js'),
errors: [
error(`exported declaration 'a' not used within other modules`),
error(`exported declaration 'b' not used within other modules`),
error(`exported declaration 'c' not used within other modules`),
error(`exported declaration 'default' not used within other modules`),
]}),
],
})
typescriptRuleTester.run('no-unused-modules', rule, {
valid: [
test({ options: unusedExportsTypescriptOptions,
code: `
export const ts_a = 10
export const ts_b = 20
export const ts_c = 30
const ts_d = 40
export default ts_d
`,
parser: require.resolve('@typescript-eslint/parser'),
filename: testFilePath('./no-unused-modules/typescript/exports-for-dynamic-ts.ts')}),
],
invalid: [
],
})

// // test for export from
ruleTester.run('no-unused-modules', rule, {
valid: [
Expand Down
46 changes: 42 additions & 4 deletions utils/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,33 @@ exports.__esModule = true

const moduleRequire = require('./module-require').default
const extname = require('path').extname
const fs = require('fs')

const log = require('debug')('eslint-plugin-import:parse')

function getBabelVisitorKeys(parserPath) {
const hypotheticalLocation = parserPath.replace('index.js', 'visitor-keys.js')
if (fs.existsSync(hypotheticalLocation)) {
const keys = moduleRequire(parserPath.replace('index.js', 'visitor-keys.js'))
return keys
} else {
return null
}
}

function keysFromParser(parserPath, parserInstance, parsedResult) {
if (/.*espree.*/.test(parserPath)) {
return parserInstance.VisitorKeys
} else if (/.*babel-eslint.*/.test(parserPath)) {
return getBabelVisitorKeys(parserPath)
} else if (/.*@typescript-eslint\/parser/.test(parserPath)) {
if (parsedResult) {
return parsedResult.visitorKeys
}
}
return null
}

exports.default = function parse(path, content, context) {

if (context == null) throw new Error('need context to parse properly')
Expand Down Expand Up @@ -45,7 +69,12 @@ exports.default = function parse(path, content, context) {
if (typeof parser.parseForESLint === 'function') {
let ast
try {
ast = parser.parseForESLint(content, parserOptions).ast
const parserRaw = parser.parseForESLint(content, parserOptions)
ast = parserRaw.ast
return {
ast,
visitorKeys: keysFromParser(parserPath, parser, parserRaw),
}
} catch (e) {
console.warn()
console.warn('Error while parsing ' + parserOptions.filePath)
Expand All @@ -55,16 +84,25 @@ exports.default = function parse(path, content, context) {
console.warn(
'`parseForESLint` from parser `' +
parserPath +
'` is invalid and will just be ignored'
'` is invalid and will just be ignored',
path
)
} else {
return ast
return {
ast,
visitorKeys: keysFromParser(parserPath, parser, undefined),
}
}
}

return parser.parse(content, parserOptions)
const keys = keysFromParser(parserPath, parser, undefined)
return {
ast: parser.parse(content, parserOptions),
visitorKeys: keys,
}
}


function getParserPath(path, context) {
const parsers = context.settings['import/parsers']
if (parsers != null) {
Expand Down
5 changes: 2 additions & 3 deletions utils/unambiguous.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use strict'
exports.__esModule = true


const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))/m
const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m
/**
* detect possible imports/exports without a full parse.
*
Expand All @@ -26,5 +25,5 @@ const unambiguousNodeType = /^(?:(?:Exp|Imp)ort.*Declaration|TSExportAssignment)
* @return {Boolean}
*/
exports.isModule = function isUnambiguousModule(ast) {
return ast.body.some(node => unambiguousNodeType.test(node.type))
return ast.body && ast.body.some(node => unambiguousNodeType.test(node.type))
}
28 changes: 28 additions & 0 deletions utils/visit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
exports.__esModule = true

exports.default = function visit(node, keys, visitorSpec) {
if (!node || !keys) {
return
}
const type = node.type
if (typeof visitorSpec[type] === 'function') {
visitorSpec[type](node)
}
const childFields = keys[type]
if (!childFields) {
return
}
for (const fieldName of childFields) {
const field = node[fieldName]
if (Array.isArray(field)) {
for (const item of field) {
visit(item, keys, visitorSpec)
}
} else {
visit(field, keys, visitorSpec)
}
}
if (typeof visitorSpec[`${type}:Exit`] === 'function') {
visitorSpec[`${type}:Exit`](node)
}
}

0 comments on commit 533df7f

Please sign in to comment.