Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deep namespaces #157

Merged
merged 9 commits into from
Feb 24, 2016
47 changes: 45 additions & 2 deletions docs/rules/namespace.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,56 @@ redefinition of the namespace in an intermediate scope. Adherence to the ESLint

For [ES7], reports if an exported namespace would be empty (no names exported from the referenced module.)

TODO: examples.
Given:
```js
// @module ./named-exports
export const a = 1
const b = 2
export { b }

const c = 3
export { c as d }

export class ExportedClass { }

// ES7
export * as deep from './deep'
```
and:
```js
// @module ./deep
export const e = "MC2"
```

See what is valid and reported:

```js
// @module ./foo
import * as names from './named-exports'

function great() {
return names.a + names.b // so great https://youtu.be/ei7mb8UxEl8
}

function notGreat() {
doSomethingWith(names.c) // Reported: 'c' not found in imported namespace 'names'.

const { a, b, c } = names // also reported, only for 'c'
}

// also tunnels through re-exported namespaces!
function deepTrouble() {
doSomethingWith(names.deep.e) // fine
doSomethingWith(names.deep.f) // Reported: 'f' not found in deeply imported namespace 'names.deep'.
}

```

## Further Reading

- Lee Byron's [ES7] export proposal
- [`import/ignore`] setting
- [`jsnext:main`] (Rollup)
- [`jsnext:main`](Rollup)

[ES7]: https://github.com/leebyron/ecmascript-more-export-from
[`import/ignore`]: ../../README.md#importignore
Expand Down
38 changes: 29 additions & 9 deletions src/core/getExports.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default class ExportMap {
return m // can't continue
}


// attempt to collect module doc
ast.comments.some(c => {
if (c.type !== 'Block') return false
Expand All @@ -94,11 +95,12 @@ export default class ExportMap {
return false
})

const namespaces = new Map()

ast.body.forEach(function (n) {

if (n.type === 'ExportDefaultDeclaration') {
m.named.set('default', captureMetadata(n))
m.named.set('default', captureDoc(n))
return
}

Expand All @@ -109,18 +111,27 @@ export default class ExportMap {
return
}

// capture namespaces in case of later export
if (n.type === 'ImportDeclaration') {
let ns
if (n.specifiers.some(s => s.type === 'ImportNamespaceSpecifier' && (ns = s))) {
namespaces.set(ns.local.name, n)
}
return
}

if (n.type === 'ExportNamedDeclaration'){
// capture declaration
if (n.declaration != null) {
switch (n.declaration.type) {
case 'FunctionDeclaration':
case 'ClassDeclaration':
case 'TypeAlias': // flowtype with babel-eslint parser
m.named.set(n.declaration.id.name, captureMetadata(n))
m.named.set(n.declaration.id.name, captureDoc(n))
break
case 'VariableDeclaration':
n.declaration.declarations.forEach((d) =>
recursivePatternCapture(d.id, id => m.named.set(id.name, captureMetadata(d, n))))
recursivePatternCapture(d.id, id => m.named.set(id.name, captureDoc(d, n))))
break
}
}
Expand All @@ -129,15 +140,23 @@ export default class ExportMap {
let remoteMap
if (n.source) remoteMap = m.resolveReExport(n, path)

n.specifiers.forEach(function (s) {
n.specifiers.forEach((s) => {
const exportMeta = {}

if (s.type === 'ExportDefaultSpecifier') {
// don't add it if it is not present in the exported module
if (!remoteMap || !remoteMap.hasDefault) return
} else if (s.type === 'ExportSpecifier' && namespaces.has(s.local.name)){
let namespace = m.resolveReExport(namespaces.get(s.local.name), path)
if (namespace) exportMeta.namespace = namespace.named
} else if (s.type === 'ExportNamespaceSpecifier') {
exportMeta.namespace = remoteMap.named
}
m.named.set(s.exported.name, null)

// todo: JSDoc
m.named.set(s.exported.name, exportMeta)
})
}

})

return m
Expand All @@ -164,9 +183,9 @@ export default class ExportMap {
/**
* parse JSDoc from the first node that has leading comments
* @param {...[type]} nodes [description]
* @return {[type]} [description]
* @return {{doc: object}}
*/
function captureMetadata(...nodes) {
function captureDoc(...nodes) {
const metadata = {}

// 'some' short-circuits on first 'true'
Expand All @@ -185,11 +204,12 @@ function captureMetadata(...nodes) {
})
return true
})

return metadata
}

/**
* Traverse a patter/identifier node, calling 'callback'
* Traverse a pattern/identifier node, calling 'callback'
* for each leaf identifier.
* @param {node} pattern
* @param {Function} callback
Expand Down
87 changes: 58 additions & 29 deletions src/rules/namespace.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ module.exports = function (context) {
return imports
}

function message(identifier, namespace) {
return '\'' + identifier.name +
'\' not found in imported namespace ' +
namespace.name + '.'
function makeMessage(last, namepath) {
return `'${last.name}' not found in` +
(namepath.length > 1 ? ' deeply ' : ' ') +
`imported namespace '${namepath.join('.')}'.`
}

return {
Expand All @@ -55,45 +55,74 @@ module.exports = function (context) {
`Assignment to member of namespace '${dereference.object.name}'.`)
}

if (dereference.computed) {
context.report(dereference.property,
'Unable to validate computed reference to imported namespace \'' +
dereference.object.name + '\'.')
return
}

// go deep
var namespace = namespaces.get(dereference.object.name)
if (!namespace.has(dereference.property.name)) {
context.report( dereference.property
, message(dereference.property, dereference.object)
)
var namepath = [dereference.object.name]
// while property is namespace and parent is member expression, keep validating
while (namespace instanceof Map &&
dereference.type === 'MemberExpression') {

if (dereference.computed) {
context.report(dereference.property,
'Unable to validate computed reference to imported namespace \'' +
dereference.object.name + '\'.')
return
}

if (!namespace.has(dereference.property.name)) {
context.report(
dereference.property,
makeMessage(dereference.property, namepath))
break
}

// stash and pop
namepath.push(dereference.property.name)
namespace = namespace.get(dereference.property.name).namespace
dereference = dereference.parent
}

},

'VariableDeclarator': function ({ id, init }) {
if (init == null) return
if (id.type !== 'ObjectPattern') return
if (init.type !== 'Identifier') return
if (!namespaces.has(init.name)) return

// check for redefinition in intermediate scopes
if (declaredScope(context, init.name) !== 'module') return

const namespace = namespaces.get(init.name)

for (let property of id.properties) {
if (property.key.type !== 'Identifier') {
context.report({
node: property,
message: 'Only destructure top-level names.',
})
} else if (!namespace.has(property.key.name)) {
context.report({
node: property,
message: message(property.key, init),
})
// DFS traverse child namespaces
function testKey(pattern, namespace, path = [init.name]) {
if (!(namespace instanceof Map)) return

if (pattern.type !== 'ObjectPattern') return

for (let property of pattern.properties) {

if (property.key.type !== 'Identifier') {
context.report({
node: property,
message: 'Only destructure top-level names.',
})
continue
}

if (!namespace.has(property.key.name)) {
context.report({
node: property,
message: makeMessage(property.key, path),
})
continue
}

path.push(property.key.name)
testKey(property.value, namespace.get(property.key.name).namespace, path)
path.pop()
}
}

testKey(id, namespaces.get(init.name))
},
}
}
1 change: 1 addition & 0 deletions tests/files/deep-es7/a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as b from './b'
1 change: 1 addition & 0 deletions tests/files/deep-es7/b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as c from './c'
1 change: 1 addition & 0 deletions tests/files/deep-es7/c.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as d from './d'
1 change: 1 addition & 0 deletions tests/files/deep-es7/d.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const e = "e"
2 changes: 2 additions & 0 deletions tests/files/deep/a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as b from './b'
export { b }
2 changes: 2 additions & 0 deletions tests/files/deep/b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as c from './c'
export { c }
2 changes: 2 additions & 0 deletions tests/files/deep/c.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import * as d from './d'
export { d }
1 change: 1 addition & 0 deletions tests/files/deep/d.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const e = "e"
19 changes: 19 additions & 0 deletions tests/src/core/getExports.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,23 @@ describe('getExports', function () {
})
})

context('exported static namespaces', function () {
const espreeContext = { parserPath: 'espree', parserOptions: { sourceType: 'module' }, settings: {} }
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)
expect(a.errors).to.be.empty
expect(a.named.get('b').namespace).to.exist
expect(a.named.get('b').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)
expect(a.errors).to.be.empty
expect(a.named.get('b').namespace).to.exist
expect(a.named.get('b').namespace.has('c')).to.be.true
})
})

})
Loading