packages feed

purescript-0.15.5: src/Language/PureScript/Bundle.hs

-- |
-- Bundles compiled PureScript modules for the browser.
--
-- This module takes as input the individual generated modules from 'Language.PureScript.Make' and
-- performs dead code elimination, filters empty modules,
-- and generates the final JavaScript bundle.
module Language.PureScript.Bundle
  ( ModuleIdentifier(..)
  , ModuleType(..)
  , ErrorMessage(..)
  , printErrorMessage
  , ForeignModuleExports(..)
  , getExportedIdentifiers
  , ForeignModuleImports(..)
  , getImportedModules
  , Module
  ) where

import Prelude

import Control.Monad.Error.Class

import Data.Aeson ((.=))
import Data.Char (chr, digitToInt)
import Data.Foldable (fold)
import Data.Maybe (mapMaybe, maybeToList)
import qualified Data.Aeson as A
import qualified Data.Text.Lazy as LT

import Language.JavaScript.Parser
import Language.JavaScript.Parser.AST
import Language.JavaScript.Process.Minify

-- | The type of error messages. We separate generation and rendering of errors using a data
-- type, in case we need to match on error types later.
data ErrorMessage
  = UnsupportedModulePath String
  | InvalidTopLevel
  | UnableToParseModule String
  | UnsupportedImport
  | UnsupportedExport
  | ErrorInModule ModuleIdentifier ErrorMessage
  | MissingEntryPoint String
  | MissingMainModule String
  deriving (Show)

-- | Modules are either "regular modules" (i.e. those generated by the PureScript compiler) or
-- foreign modules.
data ModuleType
  = Regular
  | Foreign
  deriving (Show, Eq, Ord)

showModuleType :: ModuleType -> String
showModuleType Regular = "Regular"
showModuleType Foreign = "Foreign"

-- | A module is identified by its module name and its type.
data ModuleIdentifier = ModuleIdentifier String ModuleType deriving (Show, Eq, Ord)

instance A.ToJSON ModuleIdentifier where
  toJSON (ModuleIdentifier name mt) =
    A.object [ "name" .= name
             , "type" .= show mt
             ]

data Visibility
  = Public
  | Internal
  deriving (Show, Eq, Ord)

-- | A piece of code is identified by its module, its name, and whether it is an internal variable
-- or a public member. These keys are used to label vertices in the dependency graph.
type Key = (ModuleIdentifier, String, Visibility)

-- | An export is either a "regular export", which exports a name from the regular module we are in,
-- or a reexport of a declaration in the corresponding foreign module.
--
-- Regular exports are labelled, since they might re-export an operator with another name.
data ExportType
  = RegularExport String
  | ForeignReexport
  deriving (Show, Eq, Ord)

-- | There are four types of module element we are interested in:
--
-- 1) Import declarations and require statements
-- 2) Member declarations
-- 3) Export lists
-- 4) Everything else
--
-- Each is labelled with the original AST node which generated it, so that we can dump it back
-- into the output during codegen.
data ModuleElement
  = Import JSModuleItem String (Either String ModuleIdentifier)
  | Member JSStatement Visibility String JSExpression [Key]
  | ExportsList [(ExportType, String, JSExpression, [Key])]
  | Other JSStatement
  | Skip JSModuleItem
  deriving (Show)

instance A.ToJSON ModuleElement where
  toJSON = \case
    (Import _ name (Right target)) ->
      A.object [ "type"   .= A.String "Import"
               , "name"   .= name
               , "target" .= target
               ]
    (Import _ name (Left targetPath)) ->
      A.object [ "type"       .= A.String "Import"
               , "name"       .= name
               , "targetPath" .= targetPath
               ]
    (Member _ visibility name _ dependsOn) ->
      A.object [ "type"       .= A.String "Member"
               , "name"       .= name
               , "visibility" .= show visibility
               , "dependsOn"  .= map keyToJSON dependsOn
               ]
    (ExportsList exports) ->
      A.object [ "type"    .= A.String "ExportsList"
               , "exports" .= map exportToJSON exports
               ]
    (Other stmt) ->
      A.object [ "type" .= A.String "Other"
               , "js"   .= getFragment (JSAstStatement stmt JSNoAnnot)
               ]
    (Skip item) ->
      A.object [ "type" .= A.String "Skip"
               , "js"   .= getFragment (JSAstModule [item] JSNoAnnot)
               ]

    where

    keyToJSON (mid, member, visibility) =
      A.object [ "module"     .= mid
               , "member"     .= member
               , "visibility" .= show visibility
               ]

    exportToJSON (RegularExport sourceName, name, _, dependsOn) =
      A.object [ "type"       .= A.String "RegularExport"
               , "name"       .= name
               , "sourceName" .= sourceName
               , "dependsOn"  .= map keyToJSON dependsOn
               ]
    exportToJSON (ForeignReexport, name, _, dependsOn) =
      A.object [ "type"      .= A.String "ForeignReexport"
               , "name"      .= name
               , "dependsOn" .= map keyToJSON dependsOn
               ]

    getFragment = ellipsize . renderToText . minifyJS
      where
      ellipsize text = if LT.compareLength text 20 == GT then LT.take 19 text `LT.snoc` ellipsis else text
      ellipsis = '\x2026'

-- | A module is just a list of elements of the types listed above.
data Module = Module ModuleIdentifier (Maybe FilePath) [ModuleElement] deriving (Show)

instance A.ToJSON Module where
  toJSON (Module moduleId filePath elements) =
    A.object [ "moduleId" .= moduleId
             , "filePath" .= filePath
             , "elements" .= elements
             ]

-- | Prepare an error message for consumption by humans.
printErrorMessage :: ErrorMessage -> [String]
printErrorMessage (UnsupportedModulePath s) =
  [ "An ES or CommonJS module has an unsupported name (" ++ show s ++ ")."
  , "The following file names are supported:"
  , "  1) index.js (PureScript native modules)"
  , "  2) foreign.js (PureScript ES foreign modules)"
  , "  3) foreign.cjs (PureScript CommonJS foreign modules)"
  ]
printErrorMessage InvalidTopLevel =
  [ "Expected a list of source elements at the top level." ]
printErrorMessage (UnableToParseModule err) =
  [ "The module could not be parsed:"
  , err
  ]
printErrorMessage UnsupportedImport =
  [ "An import was unsupported."
  , "Modules can be imported with ES namespace imports declarations:"
  , "  import * as module from \"Module.Name\""
  , "Alternatively, they can be also be imported with the CommonJS require function:"
  , "  var module = require(\"Module.Name\")"
  ]
printErrorMessage UnsupportedExport =
  [ "An export was unsupported."
  , "Declarations can be exported as ES named exports:"
  , "  export var decl"
  , "Existing identifiers can be exported as well:"
  , "  export { name }"
  , "They can also be renamed on export:"
  , "  export { name as alias }"
  , "Alternatively, CommonJS exports can be defined in one of two ways:"
  , "  1) exports.name = value"
  , "  2) exports = { name: value }"
  ]
printErrorMessage (ErrorInModule mid e) =
  ("Error in module " ++ displayIdentifier mid ++ ":")
  : ""
  : map ("  " ++) (printErrorMessage e)
  where
    displayIdentifier (ModuleIdentifier name ty) =
      name ++ " (" ++ showModuleType ty ++ ")"
printErrorMessage (MissingEntryPoint mName) =
  [ "Could not find an ES module or CommonJS module for the specified entry point: " ++ mName
  ]
printErrorMessage (MissingMainModule mName) =
  [ "Could not find an ES module or CommonJS module for the specified main module: " ++ mName
  ]

-- String literals include the quote chars
fromStringLiteral :: JSExpression -> Maybe String
fromStringLiteral (JSStringLiteral _ str) = Just $ strValue str
fromStringLiteral _ = Nothing

strValue :: String -> String
strValue str = go $ drop 1 str
  where
  go ('\\' : 'b' : xs) = '\b' : go xs
  go ('\\' : 'f' : xs) = '\f' : go xs
  go ('\\' : 'n' : xs) = '\n' : go xs
  go ('\\' : 'r' : xs) = '\r' : go xs
  go ('\\' : 't' : xs) = '\t' : go xs
  go ('\\' : 'v' : xs) = '\v' : go xs
  go ('\\' : '0' : xs) = '\0' : go xs
  go ('\\' : 'x' : a : b : xs) = chr (a' + b') : go xs
    where
    a' = 16 * digitToInt a
    b' = digitToInt b
  go ('\\' : 'u' : a : b : c : d : xs) = chr (a' + b' + c' + d') : go xs
    where
    a' = 16 * 16 * 16 * digitToInt a
    b' = 16 * 16 * digitToInt b
    c' = 16 * digitToInt c
    d' = digitToInt d
  go ('\\' : x : xs) = x : go xs
  go "\"" = ""
  go "'" = ""
  go (x : xs) = x : go xs
  go "" = ""

commaList :: JSCommaList a -> [a]
commaList JSLNil = []
commaList (JSLOne x) = [x]
commaList (JSLCons l _ x) = commaList l ++ [x]

trailingCommaList :: JSCommaTrailingList a -> [a]
trailingCommaList (JSCTLComma l _) = commaList l
trailingCommaList (JSCTLNone l) = commaList l

identName :: JSIdent -> Maybe String
identName (JSIdentName _ ident) = Just ident
identName _ = Nothing

exportStatementIdentifiers :: JSStatement -> [String]
exportStatementIdentifiers (JSVariable _ jsExpressions _) =
  varNames jsExpressions
exportStatementIdentifiers (JSConstant _ jsExpressions _) =
  varNames jsExpressions
exportStatementIdentifiers (JSLet _ jsExpressions _) =
  varNames jsExpressions
exportStatementIdentifiers (JSClass _ jsIdent _ _ _ _ _) =
  maybeToList . identName $ jsIdent
exportStatementIdentifiers (JSFunction _ jsIdent _ _ _ _ _) =
  maybeToList . identName $ jsIdent
exportStatementIdentifiers (JSGenerator _ _ jsIdent _ _ _ _ _) =
  maybeToList . identName $ jsIdent
exportStatementIdentifiers _ = []

varNames :: JSCommaList JSExpression -> [String]
varNames = mapMaybe varName . commaList
  where
  varName (JSVarInitExpression (JSIdentifier _ ident) _) = Just ident
  varName _ = Nothing

data ForeignModuleExports =
  ForeignModuleExports
    { cjsExports :: [String]
    , esExports :: [String]
    } deriving (Show)

instance Semigroup ForeignModuleExports where
  (ForeignModuleExports cjsExports esExports) <> (ForeignModuleExports cjsExports' esExports') =
    ForeignModuleExports (cjsExports <> cjsExports') (esExports <> esExports')
instance Monoid ForeignModuleExports where
  mempty = ForeignModuleExports [] []

-- Get a list of all the exported identifiers from a foreign module.
--
-- TODO: what if we assign to exports.foo and then later assign to
-- module.exports (presumably overwriting exports.foo)?
getExportedIdentifiers :: forall m. (MonadError ErrorMessage m)
                          => String
                          -> JSAST
                          -> m ForeignModuleExports
getExportedIdentifiers mname top
  | JSAstModule jsModuleItems _ <- top = fold <$> traverse go jsModuleItems
  | otherwise = err InvalidTopLevel
  where
  err :: ErrorMessage -> m a
  err = throwError . ErrorInModule (ModuleIdentifier mname Foreign)

  go (JSModuleStatementListItem jsStatement)
    | Just props <- matchExportsAssignment jsStatement
    = do cjsExports <- traverse toIdent (trailingCommaList props)
         pure ForeignModuleExports{ cjsExports, esExports = [] }
    | Just (Public, name, _) <- matchMember jsStatement
    = pure ForeignModuleExports{ cjsExports = [name], esExports = [] }
    | otherwise
    = pure mempty
  go (JSModuleExportDeclaration _ jsExportDeclaration) =
    pure ForeignModuleExports{ cjsExports = [], esExports = exportDeclarationIdentifiers jsExportDeclaration }
  go _ = pure mempty

  toIdent (JSPropertyNameandValue name _ [_]) =
    extractLabel' name
  toIdent _ =
    err UnsupportedExport

  extractLabel' = maybe (err UnsupportedExport) pure . extractLabel

  exportDeclarationIdentifiers (JSExportFrom jsExportClause _ _) =
    exportClauseIdentifiers jsExportClause
  exportDeclarationIdentifiers (JSExportLocals jsExportClause _) =
    exportClauseIdentifiers jsExportClause
  exportDeclarationIdentifiers (JSExport jsStatement _) =
    exportStatementIdentifiers jsStatement

  exportClauseIdentifiers (JSExportClause _ jsExportsSpecifiers _) =
    mapMaybe exportSpecifierName $ commaList jsExportsSpecifiers

  exportSpecifierName (JSExportSpecifier jsIdent) = identName jsIdent
  exportSpecifierName (JSExportSpecifierAs _ _ jsIdentAs) = identName jsIdentAs

data ForeignModuleImports =
  ForeignModuleImports
    { cjsImports :: [String]
    , esImports :: [String]
    } deriving (Show)

instance Semigroup ForeignModuleImports where
  (ForeignModuleImports cjsImports esImports) <> (ForeignModuleImports cjsImports' esImports') =
    ForeignModuleImports (cjsImports <> cjsImports') (esImports <> esImports')
instance Monoid ForeignModuleImports where
  mempty = ForeignModuleImports [] []

-- Get a list of all the imported module identifiers from a foreign module.
getImportedModules :: forall m. (MonadError ErrorMessage m)
                          => String
                          -> JSAST
                          -> m ForeignModuleImports
getImportedModules mname top
  | JSAstModule jsModuleItems _ <- top = pure $ foldMap go jsModuleItems
  | otherwise = err InvalidTopLevel
  where
  err :: ErrorMessage -> m a
  err = throwError . ErrorInModule (ModuleIdentifier mname Foreign)

  go (JSModuleStatementListItem jsStatement)
    | Just (_, mid) <- matchRequire jsStatement
    = ForeignModuleImports{ cjsImports = [mid], esImports = [] }
  go (JSModuleImportDeclaration _ jsImportDeclaration) =
    ForeignModuleImports{ cjsImports = [], esImports = [importDeclarationModuleId jsImportDeclaration] }
  go _ = mempty

  importDeclarationModuleId (JSImportDeclaration _ (JSFromClause _ _ mid) _) = mid
  importDeclarationModuleId (JSImportDeclarationBare _ mid _) = mid

-- Matches JS statements like this:
-- var ModuleName = require("file");
matchRequire :: JSStatement -> Maybe (String, String)
matchRequire stmt
  | JSVariable _ jsInit _ <- stmt
  , [JSVarInitExpression var varInit] <- commaList jsInit
  , JSIdentifier _ importName <- var
  , JSVarInit _ jsInitEx <- varInit
  , JSMemberExpression req _ argsE _ <- jsInitEx
  , JSIdentifier _ "require" <- req
  , [ Just importPath ] <- map fromStringLiteral (commaList argsE)
  = Just (importName, importPath)
  | otherwise
  = Nothing

-- Matches JS member declarations.
matchMember :: JSStatement -> Maybe (Visibility, String, JSExpression)
matchMember stmt
  | Just (name, decl) <- matchInternalMember stmt
  = pure (Internal, name, decl)
  -- exports.foo = expr; exports["foo"] = expr;
  | JSAssignStatement e (JSAssign _) decl _ <- stmt
  , Just name <- exportsAccessor e
  = Just (Public, name, decl)
  | otherwise
  = Nothing

matchInternalMember :: JSStatement -> Maybe (String, JSExpression)
matchInternalMember stmt
  -- var foo = expr;
  | JSVariable _ jsInit _ <- stmt
  , [JSVarInitExpression var varInit] <- commaList jsInit
  , JSIdentifier _ name <- var
  , JSVarInit _ decl <- varInit
  = pure (name, decl)
  -- function foo(...args) { body }
  | JSFunction a0 jsIdent a1 args a2 body _ <- stmt
  , JSIdentName _ name <- jsIdent
  = pure (name, JSFunctionExpression a0 jsIdent a1 args a2 body)
  | otherwise
  = Nothing

-- Matches exports.* or exports["*"] expressions and returns the property name.
exportsAccessor :: JSExpression -> Maybe String
exportsAccessor (JSMemberDot exports _ nm)
  | JSIdentifier _ "exports" <- exports
  , JSIdentifier _ name <- nm
  = Just name
exportsAccessor (JSMemberSquare exports _ nm _)
  | JSIdentifier _ "exports" <- exports
  , Just name <- fromStringLiteral nm
  = Just name
exportsAccessor _ = Nothing

-- Matches assignments to module.exports, like this:
-- module.exports = { ... }
matchExportsAssignment :: JSStatement -> Maybe JSObjectPropertyList
matchExportsAssignment stmt
  | JSAssignStatement e (JSAssign _) decl _ <- stmt
  , JSMemberDot module' _ exports <- e
  , JSIdentifier _ "module" <- module'
  , JSIdentifier _ "exports" <- exports
  , JSObjectLiteral _ props _ <- decl
  = Just props
  | otherwise
  = Nothing

extractLabel :: JSPropertyName -> Maybe String
extractLabel (JSPropertyString _ nm) = Just $ strValue nm
extractLabel (JSPropertyIdent _ nm) = Just nm
extractLabel _ = Nothing