Skip to content

Commit

Permalink
Merge pull request #1320 from bradzacher/export-ts-namespaces
Browse files Browse the repository at this point in the history
[fix] `export`: Support typescript namespaces

Fixes #1300.
  • Loading branch information
ljharb authored Apr 12, 2019
2 parents 70c3679 + 988e12b commit 8a4226d
Show file tree
Hide file tree
Showing 2 changed files with 215 additions and 37 deletions.
86 changes: 68 additions & 18 deletions src/rules/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) {
Expand All @@ -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))
}
}
},
Expand All @@ -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,
Expand All @@ -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, '')}'.`
)
}
}
}
}
},
Expand Down
166 changes: 147 additions & 19 deletions tests/src/rules/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
],
})
})
})

0 comments on commit 8a4226d

Please sign in to comment.