import { Promise } from 'sander' ;
import { parse } from 'acorn' ;
import MagicString from 'magic-string' ;
import Statement from './Statement' ;
import walk from './ast/walk' ;
import { blank , keys } from './utils/object' ;
import { first , sequence } from './utils/promise' ;
import getLocation from './utils/getLocation' ;
import makeLegalIdentifier from './utils/makeLegalIdentifier' ;
const emptyPromise = Promise . resolve ( ) ;
function deconflict ( name , names ) {
while ( name in names ) {
name = ` _ ${ name } ` ;
}
return name ;
}
function isEmptyExportedVarDeclaration ( node , allBundleExports , moduleReplacements ) {
if ( node . type !== 'VariableDeclaration' || node . declarations [ 0 ] . init ) return false ;
const name = node . declarations [ 0 ] . id . name ;
const canonicalName = moduleReplacements [ name ] || name ;
return canonicalName in allBundleExports ;
}
export default class Module {
constructor ( { id , source , ast , bundle } ) {
this . source = source ;
this . bundle = bundle ;
this . id = id ;
// By default, `id` is the filename. Custom resolvers and loaders
// can change that, but it makes sense to use it for the source filename
this . magicString = new MagicString ( source , {
filename : id
} ) ;
// remove existing sourceMappingURL comments
const pattern = /\/\/#\s+sourceMappingURL=.+\n?/g ;
let match ;
while ( match = pattern . exec ( source ) ) {
this . magicString . remove ( match . index , match . index + match [ 0 ] . length ) ;
}
this . suggestedNames = blank ( ) ;
this . comments = [ ] ;
this . statements = this . parse ( ast ) ;
// imports and exports, indexed by ID
this . imports = blank ( ) ;
this . exports = blank ( ) ;
this . exportAlls = blank ( ) ;
// array of all-export sources
this . exportDelegates = [ ] ;
this . replacements = blank ( ) ;
this . definitions = blank ( ) ;
this . definitionPromises = blank ( ) ;
this . modifications = blank ( ) ;
this . analyse ( ) ;
}
addExport ( statement ) {
const node = statement . node ;
const source = node . source && node . source . value ;
// export default function foo () {}
// export default foo;
// export default 42;
if ( node . type === 'ExportDefaultDeclaration' ) {
const isDeclaration = /Declaration$/ . test ( node . declaration . type ) ;
const isAnonymous = /(?:Class|Function)Expression$/ . test ( node . declaration . type ) ;
const identifier = isDeclaration ?
node . declaration . id . name :
node . declaration . type === 'Identifier' ?
node . declaration . name :
null ;
this . exports . default = {
statement ,
name : 'default' ,
localName : identifier || 'default' ,
identifier ,
isDeclaration ,
isAnonymous ,
isModified : false // in case of `export default foo; foo = somethingElse`
} ;
}
// export { foo, bar, baz }
// export var foo = 42;
// export function foo () {}
else if ( node . type === 'ExportNamedDeclaration' ) {
if ( node . specifiers . length ) {
// export { foo, bar, baz }
node . specifiers . forEach ( specifier => {
const localName = specifier . local . name ;
const exportedName = specifier . exported . name ;
// export { foo } from './foo';
if ( source ) {
this . imports [ localName ] = {
source ,
localName : exportedName ,
name : localName
} ;
}
this . exports [ exportedName ] = {
statement ,
localName ,
exportedName ,
linkedImport : source ? this . imports [ localName ] : null
} ;
} ) ;
}
else {
let declaration = node . declaration ;
let name ;
if ( declaration . type === 'VariableDeclaration' ) {
// export var foo = 42
name = declaration . declarations [ 0 ] . id . name ;
} else {
// export function foo () {}
name = declaration . id . name ;
}
this . exports [ name ] = {
statement ,
localName : name ,
expression : declaration
} ;
}
}
// Store `export * from '...'` statements in an array of delegates.
// When an unknown import is encountered, we see if one of them can satisfy it.
else {
this . exportDelegates . push ( {
statement ,
source
} ) ;
}
}
addImport ( statement ) {
const node = statement . node ;
const source = node . source . value ;
node . specifiers . forEach ( specifier => {
const isDefault = specifier . type === 'ImportDefaultSpecifier' ;
const isNamespace = specifier . type === 'ImportNamespaceSpecifier' ;
const localName = specifier . local . name ;
const name = isDefault ? 'default' : isNamespace ? '*' : specifier . imported . name ;
if ( this . imports [ localName ] ) {
const err = new Error ( ` Duplicated import ' ${ localName } ' ` ) ;
err . file = this . id ;
err . loc = getLocation ( this . source , specifier . start ) ;
throw err ;
}
this . imports [ localName ] = {
source ,
name ,
localName
} ;
} ) ;
}
analyse ( ) {
// discover this module's imports and exports
this . statements . forEach ( statement => {
if ( statement . isImportDeclaration ) this . addImport ( statement ) ;
else if ( statement . isExportDeclaration ) this . addExport ( statement ) ;
statement . analyse ( ) ;
// consolidate names that are defined/modified in this module
keys ( statement . defines ) . forEach ( name => {
this . definitions [ name ] = statement ;
} ) ;
keys ( statement . modifies ) . forEach ( name => {
( this . modifications [ name ] || ( this . modifications [ name ] = [ ] ) ) . push ( statement ) ;
} ) ;
} ) ;
// if names are referenced that are neither defined nor imported
// in this module, we assume that they're globals
this . statements . forEach ( statement => {
keys ( statement . dependsOn ) . forEach ( name => {
if ( ! this . definitions [ name ] && ! this . imports [ name ] ) {
this . bundle . assumedGlobals [ name ] = true ;
}
} ) ;
} ) ;
}
consolidateDependencies ( ) {
let strongDependencies = blank ( ) ;
this . statements . forEach ( statement => {
if ( statement . isImportDeclaration && ! statement . node . specifiers . length && ! statement . module . isExternal ) {
// include module for its side-effects
strongDependencies [ statement . module . id ] = statement . module ; // TODO is this right? `statement.module` should be `this`, surely?
}
keys ( statement . stronglyDependsOn ) . forEach ( name => {
if ( statement . defines [ name ] ) return ;
const exportAllDeclaration = this . exportAlls [ name ] ;
if ( exportAllDeclaration && exportAllDeclaration . module && ! exportAllDeclaration . module . isExternal ) {
strongDependencies [ exportAllDeclaration . module . id ] = exportAllDeclaration . module ;
return ;
}
const importDeclaration = this . imports [ name ] ;
if ( importDeclaration && importDeclaration . module && ! importDeclaration . module . isExternal ) {
strongDependencies [ importDeclaration . module . id ] = importDeclaration . module ;
}
} ) ;
} ) ;
let weakDependencies = blank ( ) ;
this . statements . forEach ( statement => {
keys ( statement . dependsOn ) . forEach ( name => {
if ( statement . defines [ name ] ) return ;
const importDeclaration = this . imports [ name ] ;
if ( importDeclaration && importDeclaration . module && ! importDeclaration . module . isExternal ) {
weakDependencies [ importDeclaration . module . id ] = importDeclaration . module ;
}
} ) ;
} ) ;
return { strongDependencies , weakDependencies } ;
}
defaultName ( ) {
const defaultExport = this . exports . default ;
if ( ! defaultExport ) return null ;
const name = defaultExport . identifier && ! defaultExport . isModified ?
defaultExport . identifier :
this . replacements . default ;
return this . replacements [ name ] || name ;
}
findDefiningStatement ( name ) {
if ( this . definitions [ name ] ) return this . definitions [ name ] ;
// TODO what about `default`/`*`?
const importDeclaration = this . imports [ name ] ;
if ( ! importDeclaration ) return null ;
return Promise . resolve ( importDeclaration . module || this . bundle . fetchModule ( importDeclaration . source , this . id ) )
. then ( module => {
importDeclaration . module = module ;
return module . findDefiningStatement ( name ) ;
} ) ;
}
findDeclaration ( localName ) {
const importDeclaration = this . imports [ localName ] ;
// name was defined by another module
if ( importDeclaration ) {
const module = importDeclaration . module ;
if ( module . isExternal ) return null ;
if ( importDeclaration . name === '*' ) return null ;
if ( importDeclaration . name === 'default' ) return null ;
const exportDeclaration = module . exports [ importDeclaration . name ] ;
return module . findDeclaration ( exportDeclaration . localName ) ;
}
// name was defined by this module, if any
let i = this . statements . length ;
while ( i -- ) {
const declaration = this . statements [ i ] . scope . declarations [ localName ] ;
if ( declaration ) {
return declaration ;
}
}
return null ;
}
mark ( name ) {
// shortcut cycles
if ( this . definitionPromises [ name ] ) {
return emptyPromise ;
}
let promise ;
// The definition for this name is in a different module
if ( this . imports [ name ] ) {
const importDeclaration = this . imports [ name ] ;
importDeclaration . isUsed = true ;
promise = this . bundle . fetchModule ( importDeclaration . source , this . id )
. then ( module => {
importDeclaration . module = module ;
// suggest names. TODO should this apply to non default/* imports?
if ( importDeclaration . name === 'default' ) {
// TODO this seems ropey
const localName = importDeclaration . localName ;
let suggestion = this . suggestedNames [ localName ] || localName ;
// special case - the module has its own import by this name
while ( ! module . isExternal && module . imports [ suggestion ] ) {
suggestion = ` _ ${ suggestion } ` ;
}
module . suggestName ( 'default' , suggestion ) ;
} else if ( importDeclaration . name === '*' ) {
const localName = importDeclaration . localName ;
const suggestion = this . suggestedNames [ localName ] || localName ;
module . suggestName ( '*' , suggestion ) ;
module . suggestName ( 'default' , ` ${ suggestion } __default ` ) ;
}
if ( importDeclaration . name === 'default' && ( module . isExternal || ! module . exports . default . linkedImport ) ) { // special case - exclude `export { default } from ...`
module . needsDefault = true ;
} else if ( importDeclaration . name === '*' ) {
module . needsAll = true ;
} else {
module . needsNamed = true ;
}
if ( module . isExternal ) {
module . importedByBundle . push ( importDeclaration ) ;
return emptyPromise ;
}
if ( importDeclaration . name === '*' ) {
// we need to create an internal namespace
if ( ! ~ this . bundle . internalNamespaceModules . indexOf ( module ) ) {
this . bundle . internalNamespaceModules . push ( module ) ;
}
return module . markAllExportStatements ( ) ;
}
const exportDeclaration = module . exports [ importDeclaration . name ] ;
if ( ! exportDeclaration ) {
const noExport = new Error ( ` Module ${ module . id } does not export ${ importDeclaration . name } (imported by ${ this . id } ) ` ) ;
// See if there exists an export delegate that defines `name`.
return first ( module . exportDelegates , noExport , declaration => {
return module . bundle . fetchModule ( declaration . source , module . id ) . then ( submodule => {
declaration . module = submodule ;
return submodule . mark ( name ) . then ( result => {
if ( ! result . length ) throw noExport ;
// It's found! This module exports `name` through declaration.
// It is however not imported into this scope.
module . exportAlls [ name ] = declaration ;
declaration . statement . dependsOn [ name ] =
declaration . statement . stronglyDependsOn [ name ] = result ;
return result ;
} ) ;
} ) ;
} ) ;
}
exportDeclaration . isUsed = true ;
if ( importDeclaration . name === 'default' ) {
return exportDeclaration . statement . mark ( ) ;
}
return module . mark ( exportDeclaration . localName ) ;
} ) ;
}
else {
const statement = name === 'default' ? this . exports . default . statement : this . definitions [ name ] ;
promise = statement && statement . mark ( ) ;
}
this . definitionPromises [ name ] = promise || emptyPromise ;
return this . definitionPromises [ name ] ;
}
markAllStatements ( isEntryModule ) {
return sequence ( this . statements , statement => {
if ( statement . isIncluded ) return ; // TODO can this happen? probably not...
// skip import declarations...
if ( statement . isImportDeclaration ) {
// ...unless they're empty, in which case assume we're importing them for the side-effects
// THIS IS NOT FOOLPROOF. Probably need /*rollup: include */ or similar
if ( ! statement . node . specifiers . length ) {
return this . bundle . fetchModule ( statement . node . source . value , this . id )
. then ( module => {
statement . module = module ;
if ( module . isExternal ) {
return ;
}
return module . markAllStatements ( ) ;
} ) ;
}
return ;
}
// skip `export { foo, bar, baz }`...
if ( statement . node . type === 'ExportNamedDeclaration' && statement . node . specifiers . length ) {
// ...but ensure they are defined, if this is the entry module
if ( isEntryModule ) {
return statement . mark ( ) ;
}
return ;
}
// include everything else
return statement . mark ( ) ;
} ) ;
}
markAllExportStatements ( ) {
return sequence ( this . statements , statement => {
return statement . isExportDeclaration ?
statement . mark ( ) :
null ;
} ) ;
}
parse ( ast ) {
// The ast can be supplied programmatically (but usually won't be)
if ( ! ast ) {
// Try to extract a list of top-level statements/declarations. If
// the parse fails, attach file info and abort
try {
ast = parse ( this . source , {
ecmaVersion : 6 ,
sourceType : 'module' ,
onComment : ( block , text , start , end ) => this . comments . push ( { block , text , start , end } )
} ) ;
} catch ( err ) {
err . code = 'PARSE_ERROR' ;
err . file = this . id ; // see above - not necessarily true, but true enough
throw err ;
}
}
walk ( ast , {
enter : node => {
this . magicString . addSourcemapLocation ( node . start ) ;
this . magicString . addSourcemapLocation ( node . end ) ;
}
} ) ;
let statements = [ ] ;
let lastChar = 0 ;
let commentIndex = 0 ;
ast . body . forEach ( node => {
// special case - top-level var declarations with multiple declarators
// should be split up. Otherwise, we may end up including code we
// don't need, just because an unwanted declarator is included
if ( node . type === 'VariableDeclaration' && node . declarations . length > 1 ) {
// remove the leading var/let/const
this . magicString . remove ( node . start , node . declarations [ 0 ] . start ) ;
node . declarations . forEach ( declarator => {
const { start , end } = declarator ;
const syntheticNode = {
type : 'VariableDeclaration' ,
kind : node . kind ,
start ,
end ,
declarations : [ declarator ] ,
isSynthetic : true
} ;
const statement = new Statement ( syntheticNode , this , start , end ) ;
statements . push ( statement ) ;
} ) ;
lastChar = node . end ; // TODO account for trailing line comment
}
else {
let comment ;
do {
comment = this . comments [ commentIndex ] ;
if ( ! comment ) break ;
if ( comment . start > node . start ) break ;
commentIndex += 1 ;
} while ( comment . end < lastChar ) ;
const start = comment ? Math . min ( comment . start , node . start ) : node . start ;
const end = node . end ; // TODO account for trailing line comment
const statement = new Statement ( node , this , start , end ) ;
statements . push ( statement ) ;
lastChar = end ;
}
} ) ;
statements . forEach ( ( statement , i ) => {
const nextStatement = statements [ i + 1 ] ;
statement . next = nextStatement ? nextStatement . start : statement . end ;
} ) ;
return statements ;
}
rename ( name , replacement ) {
this . replacements [ name ] = replacement ;
}
render ( allBundleExports , moduleReplacements ) {
let magicString = this . magicString . clone ( ) ;
this . statements . forEach ( statement => {
if ( ! statement . isIncluded ) {
magicString . remove ( statement . start , statement . next ) ;
return ;
}
// skip `export { foo, bar, baz }`
if ( statement . node . type === 'ExportNamedDeclaration' ) {
// skip `export { foo, bar, baz }`
if ( statement . node . specifiers . length ) {
magicString . remove ( statement . start , statement . next ) ;
return ;
}
// skip `export var foo;` if foo is exported
if ( isEmptyExportedVarDeclaration ( statement . node . declaration , allBundleExports , moduleReplacements ) ) {
magicString . remove ( statement . start , statement . next ) ;
return ;
}
}
// skip empty var declarations for exported bindings
// (otherwise we're left with `exports.foo;`, which is useless)
if ( isEmptyExportedVarDeclaration ( statement . node , allBundleExports , moduleReplacements ) ) {
magicString . remove ( statement . start , statement . next ) ;
return ;
}
// split up/remove var declarations as necessary
if ( statement . node . isSynthetic ) {
// insert `var/let/const` if necessary
if ( ! allBundleExports [ statement . node . declarations [ 0 ] . id . name ] ) {
magicString . insert ( statement . start , ` ${ statement . node . kind } ` ) ;
}
magicString . overwrite ( statement . end , statement . next , ';\n' ) ; // TODO account for trailing newlines
}
let replacements = blank ( ) ;
let bundleExports = blank ( ) ;
keys ( statement . dependsOn )
. concat ( keys ( statement . defines ) )
. forEach ( name => {
const bundleName = moduleReplacements [ name ] || name ;
if ( allBundleExports [ bundleName ] ) {
bundleExports [ name ] = replacements [ name ] = allBundleExports [ bundleName ] ;
} else if ( bundleName !== name ) { // TODO weird structure
replacements [ name ] = bundleName ;
}
} ) ;
statement . replaceIdentifiers ( magicString , replacements , bundleExports ) ;
// modify exports as necessary
if ( statement . isExportDeclaration ) {
// remove `export` from `export var foo = 42`
if ( statement . node . type === 'ExportNamedDeclaration' && statement . node . declaration . type === 'VariableDeclaration' ) {
magicString . remove ( statement . node . start , statement . node . declaration . start ) ;
}
// remove `export` from `export class Foo {...}` or `export default Foo`
// TODO default exports need different treatment
else if ( statement . node . declaration . id ) {
magicString . remove ( statement . node . start , statement . node . declaration . start ) ;
}
else if ( statement . node . type === 'ExportDefaultDeclaration' ) {
const canonicalName = this . defaultName ( ) ;
if ( statement . node . declaration . type === 'Identifier' && canonicalName === ( moduleReplacements [ statement . node . declaration . name ] || statement . node . declaration . name ) ) {
magicString . remove ( statement . start , statement . next ) ;
return ;
}
// prevent `var undefined = sideEffectyDefault(foo)`
if ( canonicalName === undefined ) {
magicString . remove ( statement . start , statement . node . declaration . start ) ;
return ;
}
// anonymous functions should be converted into declarations
if ( statement . node . declaration . type === 'FunctionExpression' ) {
magicString . overwrite ( statement . node . start , statement . node . declaration . start + 8 , ` function ${ canonicalName } ` ) ;
} else {
magicString . overwrite ( statement . node . start , statement . node . declaration . start , ` var ${ canonicalName } = ` ) ;
}
}
else {
throw new Error ( 'Unhandled export' ) ;
}
}
} ) ;
return magicString . trim ( ) ;
}
suggestName ( defaultOrBatch , suggestion ) {
// deconflict anonymous default exports with this module's definitions
const shouldDeconflict = this . exports . default && this . exports . default . isAnonymous ;
if ( shouldDeconflict ) suggestion = deconflict ( suggestion , this . definitions ) ;
if ( ! this . suggestedNames [ defaultOrBatch ] ) {
this . suggestedNames [ defaultOrBatch ] = makeLegalIdentifier ( suggestion ) ;
}
}
}