From 988e12b390c5d3d3b9bab4037bf6b47b10afe661 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Fri, 12 Apr 2019 10:14:11 -0700 Subject: [PATCH] fix(export): Support typescript namespaces Fixes #1300 --- src/rules/export.js | 86 +++++++++++++++----- tests/src/rules/export.js | 166 +++++++++++++++++++++++++++++++++----- 2 files changed, 215 insertions(+), 37 deletions(-) diff --git a/src/rules/export.js b/src/rules/export.js index a4691bbe9..caa28e119 100644 --- a/src/rules/export.js +++ b/src/rules/export.js @@ -2,6 +2,28 @@ import ExportMap, { recursivePatternCapture } from '../ExportMap' import docsUrl from '../docsUrl' import includes from 'array-includes' +/* +Notes on Typescript namespaces aka TSModuleDeclaration: + +There are two forms: +- active namespaces: namespace Foo {} / module Foo {} +- ambient modules; declare module "eslint-plugin-import" {} + +active namespaces: +- cannot contain a default export +- cannot contain an export all +- cannot contain a multi name export (export { a, b }) +- can have active namespaces nested within them + +ambient namespaces: +- can only be defined in .d.ts files +- cannot be nested within active namespaces +- have no other restrictions +*/ + +const rootProgram = 'root' +const tsTypePrefix = 'type:' + module.exports = { meta: { type: 'problem', @@ -11,10 +33,15 @@ module.exports = { }, create: function (context) { - const named = new Map() + const namespace = new Map([[rootProgram, new Map()]]) + + function addNamed(name, node, parent, isType) { + if (!namespace.has(parent)) { + namespace.set(parent, new Map()) + } + const named = namespace.get(parent) - function addNamed(name, node, type) { - const key = type ? `${type}:${name}` : name + const key = isType ? `${tsTypePrefix}${name}` : name let nodes = named.get(key) if (nodes == null) { @@ -25,30 +52,43 @@ module.exports = { nodes.add(node) } + function getParent(node) { + if (node.parent && node.parent.type === 'TSModuleBlock') { + return node.parent.parent + } + + // just in case somehow a non-ts namespace export declaration isn't directly + // parented to the root Program node + return rootProgram + } + return { - 'ExportDefaultDeclaration': (node) => addNamed('default', node), + 'ExportDefaultDeclaration': (node) => addNamed('default', node, getParent(node)), - 'ExportSpecifier': function (node) { - addNamed(node.exported.name, node.exported) - }, + 'ExportSpecifier': (node) => addNamed(node.exported.name, node.exported, getParent(node)), 'ExportNamedDeclaration': function (node) { if (node.declaration == null) return + const parent = getParent(node) + // support for old typescript versions + const isTypeVariableDecl = node.declaration.kind === 'type' + if (node.declaration.id != null) { if (includes([ 'TSTypeAliasDeclaration', 'TSInterfaceDeclaration', ], node.declaration.type)) { - addNamed(node.declaration.id.name, node.declaration.id, 'type') + addNamed(node.declaration.id.name, node.declaration.id, parent, true) } else { - addNamed(node.declaration.id.name, node.declaration.id) + addNamed(node.declaration.id.name, node.declaration.id, parent, isTypeVariableDecl) } } if (node.declaration.declarations != null) { for (let declaration of node.declaration.declarations) { - recursivePatternCapture(declaration.id, v => addNamed(v.name, v)) + recursivePatternCapture(declaration.id, v => + addNamed(v.name, v, parent, isTypeVariableDecl)) } } }, @@ -63,11 +103,14 @@ module.exports = { remoteExports.reportErrors(context, node) return } + + const parent = getParent(node) + let any = false remoteExports.forEach((v, name) => name !== 'default' && (any = true) && // poor man's filter - addNamed(name, node)) + addNamed(name, node, parent)) if (!any) { context.report(node.source, @@ -76,13 +119,20 @@ module.exports = { }, 'Program:exit': function () { - for (let [name, nodes] of named) { - if (nodes.size <= 1) continue - - for (let node of nodes) { - if (name === 'default') { - context.report(node, 'Multiple default exports.') - } else context.report(node, `Multiple exports of name '${name}'.`) + for (let [, named] of namespace) { + for (let [name, nodes] of named) { + if (nodes.size <= 1) continue + + for (let node of nodes) { + if (name === 'default') { + context.report(node, 'Multiple default exports.') + } else { + context.report( + node, + `Multiple exports of name '${name.replace(tsTypePrefix, '')}'.` + ) + } + } } } }, diff --git a/tests/src/rules/export.js b/tests/src/rules/export.js index a7a9e8192..3aad5241e 100644 --- a/tests/src/rules/export.js +++ b/tests/src/rules/export.js @@ -126,26 +126,154 @@ context('Typescript', function () { }, } - const isLT4 = process.env.ESLINT_VERSION === '3' || process.env.ESLINT_VERSION === '2'; - const valid = [ - test(Object.assign({ - code: ` - export const Foo = 1; - export interface Foo {} - `, - }, parserConfig)), - ] - if (!isLT4) { - valid.unshift(test(Object.assign({ - code: ` - export const Foo = 1; - export type Foo = number; - `, - }, parserConfig))) - } ruleTester.run('export', rule, { - valid: valid, - invalid: [], + valid: [ + // type/value name clash + test(Object.assign({ + code: ` + export const Foo = 1; + export type Foo = number; + `, + }, parserConfig)), + test(Object.assign({ + code: ` + export const Foo = 1; + export interface Foo {} + `, + }, parserConfig)), + + // namespace + test(Object.assign({ + code: ` + export const Bar = 1; + export namespace Foo { + export const Bar = 1; + } + `, + }, parserConfig)), + test(Object.assign({ + code: ` + export type Bar = string; + export namespace Foo { + export type Bar = string; + } + `, + }, parserConfig)), + test(Object.assign({ + code: ` + export const Bar = 1; + export type Bar = string; + export namespace Foo { + export const Bar = 1; + export type Bar = string; + } + `, + }, parserConfig)), + test(Object.assign({ + code: ` + export namespace Foo { + export const Foo = 1; + export namespace Bar { + export const Foo = 2; + } + export namespace Baz { + export const Foo = 3; + } + } + `, + }, parserConfig)), + ], + invalid: [ + // type/value name clash + test(Object.assign({ + code: ` + export type Foo = string; + export type Foo = number; + `, + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 2, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 3, + }, + ], + }, parserConfig)), + + // namespace + test(Object.assign({ + code: ` + export const a = 1 + export namespace Foo { + export const a = 2; + export const a = 3; + } + `, + errors: [ + { + message: `Multiple exports of name 'a'.`, + line: 4, + }, + { + message: `Multiple exports of name 'a'.`, + line: 5, + }, + ], + }, parserConfig)), + test(Object.assign({ + code: ` + declare module 'foo' { + const Foo = 1; + export default Foo; + export default Foo; + } + `, + errors: [ + { + message: 'Multiple default exports.', + line: 4, + }, + { + message: 'Multiple default exports.', + line: 5, + }, + ], + }, parserConfig)), + test(Object.assign({ + code: ` + export namespace Foo { + export namespace Bar { + export const Foo = 1; + export const Foo = 2; + } + export namespace Baz { + export const Bar = 3; + export const Bar = 4; + } + } + `, + errors: [ + { + message: `Multiple exports of name 'Foo'.`, + line: 4, + }, + { + message: `Multiple exports of name 'Foo'.`, + line: 5, + }, + { + message: `Multiple exports of name 'Bar'.`, + line: 8, + }, + { + message: `Multiple exports of name 'Bar'.`, + line: 9, + }, + ], + }, parserConfig)), + ], }) }) })