Skip to content

Commit 1b1fe84

Browse files
committed
Check if the main function exists and is exported when bundling
1 parent e24b8b9 commit 1b1fe84

File tree

8 files changed

+180
-4
lines changed

8 files changed

+180
-4
lines changed

bin/src/Main.purs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ main = do
641641
let options = { depsOnly: false, pursArgs: List.toUnfoldable args.pursArgs, jsonErrors: false }
642642
built <- runSpago buildEnv (Build.run options)
643643
when built do
644-
bundleEnv <- runSpago env (mkBundleEnv args)
644+
bundleEnv <- runSpago env (mkBundleEnv args buildEnv)
645645
runSpago bundleEnv Bundle.run
646646
Run args@{ selectedPackage, ensureRanges, pure } -> do
647647
{ env, fetchOpts } <- mkFetchEnv { packages: mempty, selectedPackage, ensureRanges, pure, testDeps: false, isRepl: false, migrateConfig, offline }
@@ -719,8 +719,8 @@ main = do
719719
Left err -> die [ "Could not parse provided set version. Error:", show err ]
720720
Right v -> pure v
721721

722-
mkBundleEnv :: forall a. BundleArgs -> Spago (Fetch.FetchEnv a) (Bundle.BundleEnv ())
723-
mkBundleEnv bundleArgs = do
722+
mkBundleEnv :: forall a b. BundleArgs -> Build.BuildEnv b -> Spago (Fetch.FetchEnv a) (Bundle.BundleEnv ())
723+
mkBundleEnv bundleArgs { dependencies, purs } = do
724724
{ workspace, logOptions, rootPath } <- ask
725725
logDebug $ "Bundle args: " <> show bundleArgs
726726

@@ -777,7 +777,7 @@ mkBundleEnv bundleArgs = do
777777
}
778778
}
779779
esbuild <- Esbuild.getEsbuild
780-
let bundleEnv = { esbuild, logOptions, rootPath, workspace: newWorkspace, selected, bundleOptions }
780+
let bundleEnv = { esbuild, logOptions, rootPath, workspace: newWorkspace, selected, bundleOptions, purs, dependencies }
781781
pure bundleEnv
782782

783783
mkRunEnv :: forall a b. RunArgs -> Build.BuildEnv b -> Spago (Fetch.FetchEnv a) (Run.RunEnv ())

spago.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@
567567
"http-methods",
568568
"integers",
569569
"json",
570+
"language-cst-parser",
570571
"lists",
571572
"maybe",
572573
"newtype",

spago.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ package:
4141
- http-methods
4242
- integers
4343
- json
44+
- language-cst-parser
4445
- lists
4546
- maybe
4647
- newtype

src/Spago/Command/Bundle.purs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@ module Spago.Command.Bundle where
33
import Spago.Prelude
44

55
import Data.Array (all, fold, take)
6+
import Data.Array.NonEmpty as NEA
7+
import Data.Map as Map
68
import Data.String as Str
79
import Data.String.Utils (startsWith)
810
import Spago.Cmd as Cmd
11+
import Spago.Command.Build as Build
12+
import Spago.Command.Fetch as Fetch
913
import Spago.Config (BundlePlatform(..), BundleType(..), Workspace, WorkspacePackage)
1014
import Spago.Esbuild (Esbuild)
1115
import Spago.FS as FS
1216
import Spago.Generated.BuildInfo as BuildInfo
1317
import Spago.Path as Path
18+
import Spago.Purs (Purs, ModuleGraph(..))
19+
import Spago.Purs as Purs
20+
import Spago.Purs.EntryPoint as EntryPoint
1421

1522
type BundleEnv a =
1623
{ esbuild :: Esbuild
@@ -19,6 +26,8 @@ type BundleEnv a =
1926
, bundleOptions :: BundleOptions
2027
, workspace :: Workspace
2128
, selected :: WorkspacePackage
29+
, purs :: Purs
30+
, dependencies :: Fetch.PackageTransitiveDeps
2231
| a
2332
}
2433

@@ -86,6 +95,10 @@ run = do
8695
, entrypoint
8796
]
8897

98+
-- Check that the entry module exports a `main` function when bundling an app
99+
when (opts.type == BundleApp) do
100+
validateMainExport opts.module
101+
89102
-- FIXME: remove this after 2024-12-01
90103
whenM (FS.exists $ rootPath </> checkWatermarkMarkerFileName)
91104
$ unless opts.force
@@ -146,3 +159,44 @@ nodeTargetPolyfill = Str.joinWith ";"
146159
, "const __dirname = __path.dirname(__url.fileURLToPath(import.meta.url))"
147160
, "const __filename=new URL(import.meta.url).pathname"
148161
]
162+
163+
-- | Validate that the entry module declares and exports a `main` function
164+
validateMainExport :: forall a. String -> Spago (BundleEnv a) Unit
165+
validateMainExport moduleName = do
166+
{ rootPath, selected, dependencies } <- ask
167+
168+
let
169+
globs = Build.getBuildGlobs
170+
{ rootPath
171+
, dependencies: Fetch.toAllDependencies dependencies
172+
, depsOnly: false
173+
, withTests: false
174+
, selected: NEA.singleton selected
175+
}
176+
177+
Purs.graph rootPath globs [] >>= case _ of
178+
Left err -> logWarn $ "Could not verify main export: " <> show err
179+
Right (ModuleGraph graph) ->
180+
case Map.lookup moduleName graph of
181+
Nothing ->
182+
die
183+
[ "Cannot bundle app: module " <> moduleName <> " was not found in the build."
184+
, ""
185+
, "Make sure the module exists and is included in your build."
186+
]
187+
Just { path } -> do
188+
sourceCode <- FS.readTextFile (rootPath </> path)
189+
case EntryPoint.hasMainExport sourceCode of
190+
EntryPoint.MainExported -> pure unit
191+
EntryPoint.MainNotDeclared ->
192+
die
193+
[ "Cannot bundle app: module " <> moduleName <> " does not declare a `main` function."
194+
, "If you want to create a bundle without an entry point, use --bundle-type=module instead."
195+
]
196+
EntryPoint.MainNotExported ->
197+
die
198+
[ "Cannot bundle app: module " <> moduleName <> " does not export `main`."
199+
, "Add `main` to the module's export list, remove the explicit export list, or use --bundle-type=module."
200+
]
201+
EntryPoint.ParseError err ->
202+
logWarn $ "Could not verify main export: " <> err

src/Spago/Purs/EntryPoint.purs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
module Spago.Purs.EntryPoint
2+
( hasMainExport
3+
, EntryPointCheckResult(..)
4+
) where
5+
6+
import Prelude
7+
8+
import Data.Array as Array
9+
import Data.Maybe (Maybe(..))
10+
import Data.Newtype (unwrap)
11+
import Data.Tuple (snd)
12+
import PureScript.CST (RecoveredParserResult(..), parseModule)
13+
import PureScript.CST.Types as CST
14+
15+
data EntryPointCheckResult
16+
= MainExported
17+
| MainNotDeclared
18+
| MainNotExported
19+
| ParseError String
20+
21+
-- | Check if the given PureScript source code declares and exports `main`
22+
hasMainExport :: String -> EntryPointCheckResult
23+
hasMainExport sourceCode = case parseModule sourceCode of
24+
ParseSucceeded mod -> checkModule mod
25+
ParseSucceededWithErrors mod _ -> checkModule mod
26+
ParseFailed _ -> ParseError "Failed to parse module"
27+
28+
checkModule :: forall e. CST.Module e -> EntryPointCheckResult
29+
checkModule (CST.Module { header: CST.ModuleHeader { exports }, body: CST.ModuleBody { decls } }) =
30+
let
31+
hasMainDecl = Array.any isMainDecl decls
32+
isExported = case exports of
33+
Nothing -> true -- No explicit exports = everything exported
34+
Just exportList -> Array.any isMainExport (separatedToArray (unwrap exportList).value)
35+
in
36+
if not hasMainDecl then MainNotDeclared
37+
else if not isExported then MainNotExported
38+
else MainExported
39+
40+
-- | Check for DeclValue or DeclSignature with name "main"
41+
isMainDecl :: forall e. CST.Declaration e -> Boolean
42+
isMainDecl = case _ of
43+
CST.DeclValue { name } -> getIdentName name == "main"
44+
CST.DeclSignature (CST.Labeled { label }) -> getIdentName label == "main"
45+
_ -> false
46+
47+
-- | Check if export is ExportValue with name "main"
48+
isMainExport :: forall e. CST.Export e -> Boolean
49+
isMainExport = case _ of
50+
CST.ExportValue name -> getIdentName name == "main"
51+
_ -> false
52+
53+
-- | Extract the string from a Name Ident
54+
getIdentName :: CST.Name CST.Ident -> String
55+
getIdentName (CST.Name { name: CST.Ident s }) = s
56+
57+
-- | Convert Separated to Array
58+
separatedToArray :: forall a. CST.Separated a -> Array a
59+
separatedToArray (CST.Separated { head, tail }) = Array.cons head (map snd tail)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Reading Spago workspace configuration...
2+
3+
✓ Selecting package to build: test-package
4+
5+
Downloading dependencies...
6+
Building...
7+
Src Lib All
8+
Warnings 0 0 0
9+
Errors 0 0 0
10+
11+
✓ Build succeeded.
12+
13+
14+
✘ Cannot bundle app: module Main does not export `main`.
15+
Add `main` to the module's export list, remove the explicit export list, or use --bundle-type=module.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Reading Spago workspace configuration...
2+
3+
✓ Selecting package to build: test-package
4+
5+
Downloading dependencies...
6+
Building...
7+
Src Lib All
8+
Warnings 0 0 0
9+
Errors 0 0 0
10+
11+
✓ Build succeeded.
12+
13+
14+
✘ Cannot bundle app: module Main does not declare a `main` function.
15+
If you want to create a bundle without an entry point, use --bundle-type=module instead.

test/Spago/Bundle.purs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module Test.Spago.Bundle where
22

33
import Test.Prelude
44

5+
import Data.Array as Array
56
import Data.String as Str
67
import Spago.Command.Bundle (checkWatermarkMarkerFileName)
78
import Spago.FS as FS
@@ -78,6 +79,36 @@ spec = Spec.around withTempDir do
7879
spago [ "bundle" ] >>= shouldBeSuccess
7980
checkBundle (testCwd </> "index.js") (fixture "bundle-default.js")
8081

82+
Spec.it "checks that main is declared and exported when bundling app" \{ spago, fixture, testCwd } -> do
83+
spago [ "init", "--name", "test-package" ] >>= shouldBeSuccess
84+
85+
-- Module without main: app bundle fails, module bundle succeeds
86+
FS.writeTextFile (testCwd </> "src" </> "Main.purs") $ writeMain
87+
[ "import Prelude"
88+
, ""
89+
, "foo :: Int"
90+
, "foo = 42"
91+
]
92+
spago [ "build" ] >>= shouldBeSuccess
93+
spago [ "bundle", "--bundle-type", "app" ] >>= shouldBeFailureErr (fixture "bundle-no-main-error.txt")
94+
spago [ "bundle", "--bundle-type", "module", "--outfile", "bundle.js" ] >>= shouldBeSuccess
95+
96+
-- Module with main not exported: app bundle fails
97+
FS.writeTextFile (testCwd </> "src" </> "Main.purs") $ Array.intercalate "\n"
98+
[ "module Main (foo) where"
99+
, ""
100+
, "import Prelude"
101+
, "import Effect (Effect)"
102+
, ""
103+
, "foo :: Int"
104+
, "foo = 42"
105+
, ""
106+
, "main :: Effect Unit"
107+
, "main = pure unit"
108+
]
109+
spago [ "build" ] >>= shouldBeSuccess
110+
spago [ "bundle", "--bundle-type", "app" ] >>= shouldBeFailureErr (fixture "bundle-main-not-exported-error.txt")
111+
81112
where
82113
-- This is a version of `checkFixture`, but it replaces the "v0" placeholder
83114
-- in the bundle magic marker with the actual current build version. Fixture

0 commit comments

Comments
 (0)