diff --git a/.gitignore b/.gitignore index ec05c3122..9aec2c1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,20 @@ extra/node_modules extra/.cache .stack-work +# elm-format submodule build artifacts +vendor/elm-format/.stack-work/ +vendor/elm-format/**/.stack-work/ +vendor/elm-format/src/Main + +# tools node_modules +tools/*/node_modules/ +tools/**/node_modules/ + +# test artifacts +test/Test/Export/elm-stuff/ +test/Test/Export/*.js +test/Test/Export/*.d.ts + # @TESTS elm-home diff --git a/.gitmodules b/.gitmodules index 2833d4664..2d8aadb86 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "test/scenario-elm-pages-incompatible-wire/elm-pages"] path = test/scenario-elm-pages-incompatible-wire/elm-pages - url = git@github.com:dillonkearns/elm-pages.git + url = https://github.com/dillonkearns/elm-pages.git [submodule "vendor/elm-format"] path = vendor/elm-format - url = git@github.com:lamdera/elm-format.git + url = https://github.com/lamdera/elm-format.git diff --git a/COMMIT_MESSAGE.md b/COMMIT_MESSAGE.md new file mode 100644 index 000000000..33edeaed0 --- /dev/null +++ b/COMMIT_MESSAGE.md @@ -0,0 +1,66 @@ +Add --experimental-js-ts-exports flag for JavaScript/TypeScript interop + +This feature enables Elm modules to be consumed directly from JavaScript +and TypeScript projects while maintaining Elm's currying semantics. + +## Changes + +- Add `--experimental-js-ts-exports` CLI flag to `lamdera make` +- Generate ES6 module exports for all top-level functions in modules with main +- Support both curried and uncurried function calls from JavaScript +- Generate TypeScript declaration files (.d.ts) alongside JavaScript output +- Filter internal wire protocol functions (w3_ prefix) from exports + +## Implementation Details + +- Uses existing Lamdera global flag pattern for consistency +- Reuses JavaScript AST generation infrastructure +- Generates clean module.exports structure for Node.js compatibility +- Preserves Elm's currying by attaching curry property to multi-arg functions + +## Testing + +- Added comprehensive property-based tests with random Elm module generation +- Tests verify TypeScript declarations compile successfully +- Integration with existing test suite + +## Usage + +```bash +lamdera make Main.elm --experimental-js-ts-exports --output=output.js +``` + +This generates: +- output.js with exported functions +- output.d.ts with TypeScript declarations + +## Example + +Given an Elm module: +```elm +module Main exposing (main) + +greet : String -> String -> String +greet firstName lastName = + "Hello, " ++ firstName ++ " " ++ lastName + +main = ... +``` + +JavaScript usage: +```javascript +const { Main } = require('./output.js'); + +// Direct call (uncurried) +Main.greet("John", "Doe"); // "Hello, John Doe" + +// Curried call +Main.greet.curry("John")("Doe"); // "Hello, John Doe" +``` + +TypeScript gets full type safety: +```typescript +import { Main } from './output'; + +const greeting: string = Main.greet("John", "Doe"); +``` \ No newline at end of file diff --git a/builder/src/Generate.hs b/builder/src/Generate.hs index aff6a71f8..a5b40c6f8 100644 --- a/builder/src/Generate.hs +++ b/builder/src/Generate.hs @@ -2,15 +2,19 @@ module Generate ( debug , dev + , devWithExportFlag , prod , repl + , debugWithTypeScript + , devWithTypeScript + , prodWithTypeScript ) where import Prelude hiding (cycle, print) import Control.Concurrent (MVar, forkIO, newEmptyMVar, newMVar, putMVar, readMVar) -import Control.Monad (liftM2) +import Control.Monad (liftM2, sequence) import qualified Data.ByteString.Builder as B import Data.Map ((!)) import qualified Data.Map as Map @@ -27,6 +31,7 @@ import qualified Elm.ModuleName as ModuleName import qualified Elm.Package as Pkg import qualified File import qualified Generate.JavaScript as JS +import qualified Generate.TypeScript as TS import qualified Generate.Mode as Mode import qualified Nitpick.Debug as Nitpick import qualified Reporting.Exit as Exit @@ -63,13 +68,36 @@ debug root details (Build.Artifacts pkg ifaces roots modules) = return $ JS.generate mode graph mains +debugWithTypeScript :: FilePath -> Details.Details -> Build.Artifacts -> Bool -> Task (B.Builder, B.Builder) +debugWithTypeScript root details artifacts@(Build.Artifacts pkg ifaces roots modules) exportAllFunctions = + do loading <- loadObjects root details modules + types <- loadTypes root ifaces modules + objects <- finalizeObjects loading + let mode = Mode.Dev (Just types) + let graph_ = objectsToGlobalGraph objects + graph <- Task.io $ Lamdera.AppConfig.injectConfig graph_ + let mains = if exportAllFunctions + then gatherAllExports pkg objects roots + else gatherMains pkg objects roots + let jsBuilder = JS.generate mode graph mains + interfaces <- collectInterfaces root artifacts + let tsBuilder = TS.generate interfaces mains graph + return (jsBuilder, tsBuilder) + + dev :: FilePath -> Details.Details -> Build.Artifacts -> Task B.Builder -dev root details (Build.Artifacts pkg _ roots modules) = +dev root details artifacts = + devWithExportFlag root details artifacts False + +devWithExportFlag :: FilePath -> Details.Details -> Build.Artifacts -> Bool -> Task B.Builder +devWithExportFlag root details (Build.Artifacts pkg _ roots modules) exportAllFunctions = do objects <- finalizeObjects =<< loadObjects root details modules let mode = Mode.Dev Nothing let graph_ = objectsToGlobalGraph objects graph <- Task.io $ Lamdera.AppConfig.injectConfig graph_ - let mains = gatherMains pkg objects roots + let mains = if exportAllFunctions + then gatherAllExports pkg objects roots + else gatherMains pkg objects roots return $ JS.generate mode graph mains @@ -95,6 +123,75 @@ repl root details ansi (Build.ReplArtifacts home modules localizer annotations) return $ JS.generateForRepl ansi localizer graph home name (annotations ! name) +devWithTypeScript :: FilePath -> Details.Details -> Build.Artifacts -> Bool -> Task (B.Builder, B.Builder) +devWithTypeScript root details artifacts@(Build.Artifacts pkg ifaces roots modules) exportAllFunctions = + do objects <- finalizeObjects =<< loadObjects root details modules + let mode = Mode.Dev Nothing + let graph_ = objectsToGlobalGraph objects + graph <- Task.io $ Lamdera.AppConfig.injectConfig graph_ + let mains = if exportAllFunctions + then gatherAllExports pkg objects roots + else gatherMains pkg objects roots + let jsBuilder = JS.generate mode graph mains + interfaces <- collectInterfaces root artifacts + let tsBuilder = TS.generate interfaces mains graph + return (jsBuilder, tsBuilder) + + +prodWithTypeScript :: FilePath -> Details.Details -> Build.Artifacts -> Bool -> Task (B.Builder, B.Builder) +prodWithTypeScript root details artifacts@(Build.Artifacts pkg ifaces roots modules) exportAllFunctions = + do objects <- finalizeObjects =<< loadObjects root details modules + checkForDebugUses objects + let graph_ = objectsToGlobalGraph objects + graph <- Task.io $ Lamdera.AppConfig.injectConfig graph_ + longNamesEnabled <- Task.io $ Lamdera.useLongNames + let mode = Mode.Prod (Mode.shortenFieldNames graph) + & Lamdera.alternativeImplementationWhen longNamesEnabled + (Mode.Prod (Mode.legibleFieldNames graph)) + let mains = if exportAllFunctions + then gatherAllExports pkg objects roots + else gatherMains pkg objects roots + let jsBuilder = JS.generate mode graph mains + interfaces <- collectInterfaces root artifacts + let tsBuilder = TS.generate interfaces mains graph + return (jsBuilder, tsBuilder) + + + +-- COLLECT INTERFACES + + +collectInterfaces :: FilePath -> Build.Artifacts -> Task (Map.Map ModuleName.Canonical I.Interface) +collectInterfaces root (Build.Artifacts pkg deps _ modules) = Task.io $ do + let + freshInterfaces = Map.fromList + [ (ModuleName.Canonical pkg name, iface) + | Build.Fresh name iface _ <- modules + ] + + -- For cached modules, we need to load the interfaces from disk + cachedInterfaces <- fmap Map.fromList $ fmap Maybe.catMaybes $ sequence + [ do -- Try to load the interface from disk + maybeIface <- File.readBinary (Stuff.elmi root name) + case maybeIface of + Just iface -> return $ Just (ModuleName.Canonical pkg name, iface) + Nothing -> return Nothing + | Build.Cached name _ _ <- modules + ] + + let + localInterfaces = Map.union freshInterfaces cachedInterfaces + foreignInterfaces = Map.mapMaybe depInterfaceToInterface deps + + return $ Map.union localInterfaces foreignInterfaces + + +depInterfaceToInterface :: I.DependencyInterface -> Maybe I.Interface +depInterfaceToInterface dep = + case dep of + I.Public iface -> Just iface + I.Private _ _ _ -> Nothing + -- CHECK FOR DEBUG @@ -114,6 +211,11 @@ gatherMains :: Pkg.Name -> Objects -> NE.List Build.Root -> Map.Map ModuleName.C gatherMains pkg (Objects _ locals) roots = Map.fromList $ Maybe.mapMaybe (lookupMain pkg locals) (NE.toList roots) +-- Gather all exports (including modules without main) for --export-all-functions +gatherAllExports :: Pkg.Name -> Objects -> NE.List Build.Root -> Map.Map ModuleName.Canonical Opt.Main +gatherAllExports pkg (Objects _ locals) roots = + Map.fromList $ Maybe.mapMaybe (lookupMainOrExports pkg locals) (NE.toList roots) + lookupMain :: Pkg.Name -> Map.Map ModuleName.Raw Opt.LocalGraph -> Build.Root -> Maybe (ModuleName.Canonical, Opt.Main) lookupMain pkg locals root = @@ -125,6 +227,23 @@ lookupMain pkg locals root = Build.Inside name -> toPair name =<< Map.lookup name locals Build.Outside name _ g -> toPair name g +-- For --export-all-functions, create a synthetic main that references all exports +lookupMainOrExports :: Pkg.Name -> Map.Map ModuleName.Raw Opt.LocalGraph -> Build.Root -> Maybe (ModuleName.Canonical, Opt.Main) +lookupMainOrExports pkg locals root = + case lookupMain pkg locals root of + Just result -> Just result + Nothing -> + -- If no main, create a synthetic one that marks the module for export + case root of + Build.Inside name -> + case Map.lookup name locals of + Just (Opt.LocalGraph _ nodes _) -> + -- Create a synthetic main that marks this module for export + Just (ModuleName.Canonical pkg name, Opt.Static) + Nothing -> Nothing + Build.Outside name _ (Opt.LocalGraph _ nodes _) -> + Just (ModuleName.Canonical pkg name, Opt.Static) + -- LOADING OBJECTS diff --git a/compiler/src/Generate/JavaScript.hs b/compiler/src/Generate/JavaScript.hs index a800a4a26..81a78f0c3 100644 --- a/compiler/src/Generate/JavaScript.hs +++ b/compiler/src/Generate/JavaScript.hs @@ -11,6 +11,7 @@ import Prelude hiding (cycle, print) import qualified Data.ByteString.Builder as B import Data.Monoid ((<>)) import qualified Data.List as List +import Data.List (isPrefixOf) import Data.Map ((!)) import qualified Data.Map as Map import qualified Data.Name as Name @@ -22,6 +23,7 @@ import qualified AST.Optimized as Opt import qualified Data.Index as Index import qualified Elm.Kernel as K import qualified Elm.ModuleName as ModuleName +import qualified Elm.Package as Pkg import qualified Generate.JavaScript.Builder as JS import qualified Generate.JavaScript.Expression as Expr import qualified Generate.JavaScript.Functions as Functions @@ -33,9 +35,29 @@ import qualified Reporting.Render.Type.Localizer as L import qualified Lamdera.Injection +import qualified Lamdera -- GENERATE +{-| Module: Generate.JavaScript + +This module generates JavaScript code from optimized Elm AST. + +## Experimental JS/TS Exports Feature + +When --experimental-js-ts-exports is enabled via Lamdera.enableExportAllFunctions: + + * Exports all top-level functions from modules that have a 'main' function + * Generates both curried and uncurried versions for multi-argument functions + * Filters out internal wire protocol functions (w3_ prefix) + * Produces clean ES6 module exports suitable for JavaScript/TypeScript consumption + * Works in conjunction with Generate.TypeScript to produce .d.ts files + +The feature is designed for creating reusable Elm modules that can be +consumed from JavaScript/TypeScript projects while maintaining Elm's +currying semantics. +-} + type Graph = Map.Map Opt.Global Opt.Node type FnArgLookup = ModuleName.Canonical -> Name.Name -> Maybe Int @@ -43,24 +65,47 @@ type Mains = Map.Map ModuleName.Canonical Opt.Main generate :: Mode.Mode -> Opt.GlobalGraph -> Mains -> B.Builder -generate mode (Opt.GlobalGraph graph_ _) mains = +generate mode globalGraph@(Opt.GlobalGraph graph_ _) mains = let graph = Lamdera.Injection.graphModifications mode mains graph_ - state = Map.foldrWithKey (addMain mode graph) emptyState mains + exportAllFunctions = Lamdera.isExportAllFunctionsEnabled_ + state = if exportAllFunctions + then + -- For experimental JS/TS exports, add all exposed functions from modules that have mains + Map.foldrWithKey (addModuleExports mode graph) emptyState mains + else + -- Normal mode: just add main functions + Map.foldrWithKey (addMain mode graph) emptyState mains in "(function(scope){\n'use strict';" <> Functions.functions -- <> perfNote mode -- @NOTE given user never manages JS generation in Lamdera, hide the perf note <> stateToBuilder state - <> toMainExports mode mains - <> Lamdera.Injection.source mode mains - <> "}(this));" - <> "\n" <> Lamdera.Injection.elmPkgJs mode <> "\n" + <> if exportAllFunctions + then toAllExports mode globalGraph mains graph + <> "}(this));" -- Close wrapper for export-all mode + else toMainExports mode mains + <> Lamdera.Injection.source mode mains + <> "}(this));" + <> "\n" <> Lamdera.Injection.elmPkgJs mode <> "\n" addMain :: Mode.Mode -> Graph -> ModuleName.Canonical -> Opt.Main -> State -> State addMain mode graph home _ state = addGlobal mode graph state (Opt.Global home "main") +-- For --experimental-js-ts-exports: add all globals from a module +addModuleExports :: Mode.Mode -> Graph -> ModuleName.Canonical -> Opt.Main -> State -> State +addModuleExports mode graph home _ state = + -- Find all globals that belong to this module and add them + Map.foldrWithKey (\global node acc -> + case global of + Opt.Global globalHome name -> + if globalHome == home && shouldExportName name + then addGlobal mode graph acc global + else acc + ) state graph + + perfNote :: Mode.Mode -> B.Builder perfNote mode = @@ -195,73 +240,76 @@ addGlobalHelp mode graph global state = argLookup = makeArgLookup graph in - case graph ! global of - -- @LAMDERA - Opt.Define (Opt.Function args body) deps - | length args > 1 -> - addStmt - (addDeps deps state) - (fn global args (Expr.generateFunctionImplementation mode argLookup args body)) - - Opt.Define expr deps -> - addStmt (addDeps deps state) ( - var global (Expr.generate mode argLookup expr) - ) - - Opt.DefineTailFunc argNames body deps -> - addStmt (addDeps deps state) ( - let (Opt.Global _ name) = global in - var global (Expr.generateTailDef mode argLookup name argNames body) - ) - - -- @LAMDERA - Opt.Ctor index arity - | arity > 1 -> - addStmt + case Map.lookup global graph of + Nothing -> state -- Global not in graph, skip it + Just node -> + case node of + -- @LAMDERA + Opt.Define (Opt.Function args body) deps + | length args > 1 -> + addStmt + (addDeps deps state) + (fn global args (Expr.generateFunctionImplementation mode argLookup args body)) + + Opt.Define expr deps -> + addStmt (addDeps deps state) ( + var global (Expr.generate mode argLookup expr) + ) + + Opt.DefineTailFunc argNames body deps -> + addStmt (addDeps deps state) ( + let (Opt.Global _ name) = global in + var global (Expr.generateTailDef mode argLookup name argNames body) + ) + + -- @LAMDERA + Opt.Ctor index arity + | arity > 1 -> + addStmt + state + (ctor global arity (Expr.generateCtorImplementation mode global index arity)) + + Opt.Ctor index arity -> + addStmt state ( + var global (Expr.generateCtor mode global index arity) + ) + + Opt.Link linkedGlobal -> + addGlobal mode graph state linkedGlobal + + Opt.Cycle names values functions deps -> + addStmt (addDeps deps state) ( + generateCycle mode argLookup global names values functions + ) + + Opt.Manager effectsType -> + generateManager mode graph global effectsType state + + Opt.Kernel chunks deps -> + if isDebugger global && not (Mode.isDebug mode) then state - (ctor global arity (Expr.generateCtorImplementation mode global index arity)) + else + addKernel (addDeps deps state) (generateKernel mode chunks) - Opt.Ctor index arity -> - addStmt state ( - var global (Expr.generateCtor mode global index arity) - ) - - Opt.Link linkedGlobal -> - addGlobal mode graph state linkedGlobal - - Opt.Cycle names values functions deps -> - addStmt (addDeps deps state) ( - generateCycle mode argLookup global names values functions - ) - - Opt.Manager effectsType -> - generateManager mode graph global effectsType state - - Opt.Kernel chunks deps -> - if isDebugger global && not (Mode.isDebug mode) then - state - else - addKernel (addDeps deps state) (generateKernel mode chunks) + Opt.Enum index -> + addStmt state ( + generateEnum mode global index + ) - Opt.Enum index -> - addStmt state ( - generateEnum mode global index - ) + Opt.Box -> + addStmt (addGlobal mode graph state identity) ( + generateBox mode global + ) - Opt.Box -> - addStmt (addGlobal mode graph state identity) ( - generateBox mode global - ) + Opt.PortIncoming decoder deps -> + addStmt (addDeps deps state) ( + generatePort mode global "incomingPort" decoder + ) - Opt.PortIncoming decoder deps -> - addStmt (addDeps deps state) ( - generatePort mode global "incomingPort" decoder - ) - - Opt.PortOutgoing encoder deps -> - addStmt (addDeps deps state) ( - generatePort mode global "outgoingPort" encoder - ) + Opt.PortOutgoing encoder deps -> + addStmt (addDeps deps state) ( + generatePort mode global "outgoingPort" encoder + ) addStmt :: State -> JS.Stmt -> State @@ -577,6 +625,208 @@ addSubTrie mode end (name, trie) = +-- EXPORT ALL FUNCTIONS +-- @LAMDERA + + +toAllExports :: Mode.Mode -> Opt.GlobalGraph -> Mains -> Graph -> B.Builder +toAllExports mode (Opt.GlobalGraph allNodes _) mains graph = + let + -- Generate module exports for standalone use + moduleExports = generateStandaloneModuleExports mode mains graph + in + "if (typeof module !== 'undefined' && module.exports) {\n" + <> " module.exports = " <> moduleExports <> ";\n" + <> "} else if (typeof scope !== 'undefined') {\n" + <> " scope.Elm = " <> moduleExports <> ";\n" + <> "}" + +-- Generate exports for standalone modules with --export-all-functions +generateStandaloneModuleExports :: Mode.Mode -> Mains -> Graph -> B.Builder +generateStandaloneModuleExports mode mains graph = + "{\n" <> Map.foldlWithKey' (addModuleObject mode graph) "" mains <> "}" + +addModuleObject :: Mode.Mode -> Graph -> B.Builder -> ModuleName.Canonical -> Opt.Main -> B.Builder +addModuleObject mode graph acc home@(ModuleName.Canonical _ moduleName) _ = + let + moduleNameStr = Name.toBuilder moduleName + exports = generateModuleFunctionExports mode graph home + in + if B.toLazyByteString acc == "" + then " " <> moduleNameStr <> ": " <> exports + else acc <> ",\n " <> moduleNameStr <> ": " <> exports + +generateModuleFunctionExports :: Mode.Mode -> Graph -> ModuleName.Canonical -> B.Builder +generateModuleFunctionExports mode graph home = + let (exports, currySetup) = Map.foldlWithKey' (addExportedFunction mode home) ("", []) graph + in + if null currySetup + then "{\n" <> exports <> "\n }" + else "(function() {\n var _module = {\n" <> exports <> "\n };\n" + <> mconcat (reverse currySetup) <> "\n return _module;\n})()" + +addExportedFunction :: Mode.Mode -> ModuleName.Canonical -> (B.Builder, [B.Builder]) -> Opt.Global -> Opt.Node -> (B.Builder, [B.Builder]) +addExportedFunction mode home (acc, currySetup) global@(Opt.Global globalHome name) node = + if globalHome == home && shouldExportNode node && shouldExportName name + then + let + nameStr = JsName.toBuilder (JsName.fromGlobal globalHome name) + exportName = Name.toBuilder name + -- For multi-argument functions, export direct version as default and add curry property + (export, newCurrySetup) = case node of + Opt.Define (Opt.Function args _) _ -> + if length args > 1 + then (" " <> exportName <> ": " <> nameStr <> "$", + (" _module." <> exportName <> ".curry = " <> nameStr <> ";\n") : currySetup) + else (" " <> exportName <> ": " <> nameStr, currySetup) + Opt.DefineTailFunc args _ _ -> + if length args > 1 + then (" " <> exportName <> ": " <> nameStr <> "$", + (" _module." <> exportName <> ".curry = " <> nameStr <> ";\n") : currySetup) + else (" " <> exportName <> ": " <> nameStr, currySetup) + _ -> (" " <> exportName <> ": " <> nameStr, currySetup) + in + if B.toLazyByteString acc == "" + then (export, newCurrySetup) + else (acc <> ",\n" <> export, newCurrySetup) + else (acc, currySetup) + +-- | Determines if a function should be exported based on its name. +-- Filters out internal implementation details like wire protocol functions. +shouldExportName :: Name.Name -> Bool +shouldExportName name = + let nameStr = Name.toChars name + in not (isPrefixOf "w3_" nameStr) -- Filter out wire3 encode/decode functions + +-- | Determines if an AST node represents an exportable definition. +-- Includes functions, constructors, enums, and ports. +shouldExportNode :: Opt.Node -> Bool +shouldExportNode node = + case node of + Opt.Define _ _ -> True + Opt.DefineTailFunc _ _ _ -> True + Opt.Ctor _ _ -> True + Opt.Enum _ -> True + Opt.PortIncoming _ _ -> True + Opt.PortOutgoing _ _ -> True + _ -> False + + +-- Extract user modules from local compilation graph +extractLocalModules :: Mode.Mode -> Graph -> State -> Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) +extractLocalModules mode graph (State _ _ seenGlobals) = + Set.foldl (addLocalGlobal mode graph) Map.empty seenGlobals + +addLocalGlobal :: Mode.Mode -> Graph -> Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) -> Opt.Global -> Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) +addLocalGlobal mode graph acc global@(Opt.Global home name) = + case Map.lookup global graph of + Just node -> + case home of + ModuleName.Canonical _ moduleName -> + -- Only include user modules (not kernel/core modules) + if Name.isKernel moduleName || isInternalNode node + then acc + else + let + moduleMap = Map.findWithDefault Map.empty home acc + newModuleMap = Map.insert name global moduleMap + in + Map.insert home newModuleMap acc + Nothing -> acc + + +addGlobalIfExposed :: Mode.Mode -> Graph -> State -> Opt.Global -> Opt.Node -> State +addGlobalIfExposed mode graph state global@(Opt.Global home name) node = + -- Skip kernel modules and compiler-generated names + case home of + ModuleName.Canonical _ moduleName -> + if Name.isKernel moduleName || isInternalNode node + then state + else addGlobal mode graph state global + + +groupByModule :: Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) -> Opt.Global -> Opt.Node -> Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) +groupByModule acc global@(Opt.Global home name) node = + case home of + ModuleName.Canonical _ moduleName -> + if Name.isKernel moduleName || isInternalNode node + then acc + else + let + moduleMap = Map.findWithDefault Map.empty home acc + newModuleMap = Map.insert name global moduleMap + in + Map.insert home newModuleMap acc + +-- Check if a package is from elm core (elm/*, elm-explorations/*, lamdera/*) +isElmCorePackage :: Pkg.Name -> Bool +isElmCorePackage pkg = Pkg.isKernel pkg + + +-- Generate module exports for the Elm object structure +generateModuleExportsForElm :: Mode.Mode -> Map.Map ModuleName.Canonical (Map.Map Name.Name Opt.Global) -> B.Builder +generateModuleExportsForElm mode moduleGroups = + let + -- Filter to only user modules for export (not core modules) + userModuleGroups = Map.filterWithKey isUserModule moduleGroups + entries = Map.foldrWithKey (addModuleToElmExportBuilder mode) [] userModuleGroups + entriesBuilder = mconcat $ List.intersperse ", " entries + in + "{" <> entriesBuilder <> "}" + +-- Check if a module should be exported (user modules only, not core) +isUserModule :: ModuleName.Canonical -> Map.Map Name.Name Opt.Global -> Bool +isUserModule (ModuleName.Canonical pkg _) _ = not (isElmCorePackage pkg) + +addModuleToElmExportBuilder :: Mode.Mode -> ModuleName.Canonical -> Map.Map Name.Name Opt.Global -> [B.Builder] -> [B.Builder] +addModuleToElmExportBuilder mode home@(ModuleName.Canonical _ moduleName) functions acc = + let + moduleExport = generateModuleObject mode home functions + moduleNameBuilder = Name.toBuilder moduleName + entry = "'" <> moduleNameBuilder <> "': " <> moduleExport + in + entry : acc + + +generateModuleObject :: Mode.Mode -> ModuleName.Canonical -> Map.Map Name.Name Opt.Global -> B.Builder +generateModuleObject mode home functions = + let + functionExports = Map.foldrWithKey (addFunctionExport mode) "" functions + in + "(function() {\n" <> + "var module = {};\n" <> + functionExports <> + "return module;\n" <> + "}())" + + +addFunctionExport :: Mode.Mode -> Name.Name -> Opt.Global -> B.Builder -> B.Builder +addFunctionExport mode name (Opt.Global home funcName) acc = + let + jsName = JsName.toBuilder (JsName.fromGlobal home funcName) + nameStr = Utf8.toBuilder name + + -- Also export the direct version if it exists for multi-argument functions + directJsName = JsName.toBuilder (JsName.fromGlobalDirectFn home funcName) + directExport = + "if (typeof " <> directJsName <> " !== 'undefined') {\n" <> + " module['" <> nameStr <> "']['$direct'] = " <> directJsName <> ";\n" <> + "}\n" + in + "module['" <> nameStr <> "'] = " <> jsName <> ";\n" <> + directExport <> + acc + + +-- Check if a node should be considered internal (not exposed) +isInternalNode :: Opt.Node -> Bool +isInternalNode node = + case node of + Opt.Cycle _ _ _ _ -> True -- Cycles are internal + Opt.Manager _ -> True -- Effect managers are internal + _ -> False + + -- BUILD TRIES diff --git a/compiler/src/Generate/TypeScript.hs b/compiler/src/Generate/TypeScript.hs new file mode 100644 index 000000000..2f1dc3199 --- /dev/null +++ b/compiler/src/Generate/TypeScript.hs @@ -0,0 +1,644 @@ +{-# LANGUAGE OverloadedStrings #-} +module Generate.TypeScript + ( generate + , generateForModule + ) + where + +import Prelude hiding (cycle) +import qualified Data.ByteString.Builder as B +import Data.Monoid ((<>)) +import qualified Data.List as List +import qualified Data.Map as Map +import qualified Data.Name as Name +import qualified Data.Set as Set + +import qualified AST.Canonical as Can +import qualified AST.Optimized as Opt +import qualified Elm.Interface as I +import qualified Elm.ModuleName as ModuleName +import qualified Elm.Package as Pkg +import qualified Generate.JavaScript.Name as JsName + + +-- GENERATE + +type Interfaces = Map.Map ModuleName.Canonical I.Interface + +generate :: Interfaces -> Map.Map ModuleName.Canonical Opt.Main -> Opt.GlobalGraph -> B.Builder +generate ifaces mains (Opt.GlobalGraph graph _) = + Map.foldlWithKey' (addModuleDeclarations ifaces graph) "" mains <> "\n" + + +generateForModule :: Interfaces -> Opt.GlobalGraph -> ModuleName.Canonical -> B.Builder +generateForModule ifaces (Opt.GlobalGraph graph _) home = + case Map.lookup home ifaces of + Nothing -> "" + Just iface -> generateModuleInterface ifaces graph home iface + + +-- MODULE DECLARATIONS + +addModuleDeclarations :: Interfaces -> Map.Map Opt.Global Opt.Node -> B.Builder -> ModuleName.Canonical -> Opt.Main -> B.Builder +addModuleDeclarations ifaces graph acc home _ = + case Map.lookup home ifaces of + Nothing -> acc + Just iface -> + if B.toLazyByteString acc == "" + then generateModuleInterface ifaces graph home iface + else acc <> "\n\n" <> generateModuleInterface ifaces graph home iface + + +generateModuleInterface :: Interfaces -> Map.Map Opt.Global Opt.Node -> ModuleName.Canonical -> I.Interface -> B.Builder +generateModuleInterface ifaces graph home iface = + let + ModuleName.Canonical _ moduleName = home + moduleNameStr = Name.toBuilder moduleName + declarations = generateInterfaceDeclarations ifaces home iface + exports = generateModuleExports graph home iface + in + "export declare namespace " <> moduleNameStr <> " {\n" + <> declarations + <> "}\n\n" + <> "declare const " <> moduleNameStr <> ": {\n" + <> exports + <> "};\n\n" + <> "export default " <> moduleNameStr <> ";" + + +-- INTERFACE DECLARATIONS + +generateInterfaceDeclarations :: Interfaces -> ModuleName.Canonical -> I.Interface -> B.Builder +generateInterfaceDeclarations ifaces home (I.Interface _ values unions aliases _) = + let + typeDecls = Map.foldlWithKey' (addUnionDeclaration ifaces home) "" unions + <> Map.foldlWithKey' (addAliasDeclaration ifaces home) "" aliases + in + typeDecls + + +addUnionDeclaration :: Interfaces -> ModuleName.Canonical -> B.Builder -> Name.Name -> I.Union -> B.Builder +addUnionDeclaration ifaces home acc name union = + let decl = generateUnionDeclaration ifaces home name union + in if B.toLazyByteString acc == "" + then decl + else acc <> "\n" <> decl + + +addAliasDeclaration :: Interfaces -> ModuleName.Canonical -> B.Builder -> Name.Name -> I.Alias -> B.Builder +addAliasDeclaration ifaces home acc name alias = + let decl = generateAliasDeclaration ifaces home name alias + in if B.toLazyByteString acc == "" + then decl + else acc <> "\n" <> decl + + +-- UNION TYPES + +generateUnionDeclaration :: Interfaces -> ModuleName.Canonical -> Name.Name -> I.Union -> B.Builder +generateUnionDeclaration ifaces home name union = + case union of + I.OpenUnion (Can.Union vars ctors _ _) -> + generateUnionType home name vars ctors + I.ClosedUnion (Can.Union vars ctors _ _) -> + generateUnionType home name vars ctors + I.PrivateUnion _ -> + "" -- Don't generate private unions + + +generateUnionType :: ModuleName.Canonical -> Name.Name -> [Name.Name] -> [Can.Ctor] -> B.Builder +generateUnionType home name vars ctors = + let + nameStr = Name.toBuilder name + typeParams = if null vars then "" else "<" <> joinWith ", " (map (Name.toBuilder) vars) <> ">" + variants = map (generateVariant home) ctors + in + " export type " <> nameStr <> typeParams <> " = " <> joinWith " | " variants <> ";\n" + + +generateVariant :: ModuleName.Canonical -> Can.Ctor -> B.Builder +generateVariant home (Can.Ctor ctorName _ _ args) = + let + ctorNameStr = Name.toBuilder ctorName + fields = if null args + then "" + else ", " <> joinWith ", " (zipWith (generateCtorField home) [0..] args) + in + "{ $: \"" <> ctorNameStr <> "\"" <> fields <> " }" + + +generateCtorField :: ModuleName.Canonical -> Int -> Can.Type -> B.Builder +generateCtorField home index tipe = + let fieldName = if index == 0 then "a" else B.charUtf8 (toEnum (97 + index)) + in fieldName <> ": " <> generateTypeForNamespace home Map.empty tipe + + +-- ALIAS TYPES + +generateAliasDeclaration :: Interfaces -> ModuleName.Canonical -> Name.Name -> I.Alias -> B.Builder +generateAliasDeclaration ifaces home name alias = + case alias of + I.PublicAlias (Can.Alias vars tipe) -> + generateAliasType home name vars tipe + I.PrivateAlias _ -> + "" -- Don't generate private aliases + + +generateAliasType :: ModuleName.Canonical -> Name.Name -> [Name.Name] -> Can.Type -> B.Builder +generateAliasType home name vars tipe = + let + nameStr = Name.toBuilder name + typeParams = if null vars then "" else "<" <> joinWith ", " (map Name.toBuilder vars) <> ">" + in + " export type " <> nameStr <> typeParams <> " = " <> generateTypeForNamespace home Map.empty tipe <> ";\n" + + +-- MODULE EXPORTS + +generateModuleExports :: Map.Map Opt.Global Opt.Node -> ModuleName.Canonical -> I.Interface -> B.Builder +generateModuleExports graph home (I.Interface _ values unions aliases _) = + let + valueExports = Map.foldlWithKey' (addValueExport graph home) "" values + ctorExports = Map.foldlWithKey' (addCtorExports graph home) "" unions + in + valueExports <> ctorExports + + +addValueExport :: Map.Map Opt.Global Opt.Node -> ModuleName.Canonical -> B.Builder -> Name.Name -> Can.Annotation -> B.Builder +addValueExport graph home acc name (Can.Forall freeVars tipe) = + if shouldExportName name + then + let + nameStr = Name.toBuilder name + typeVars = Map.keys freeVars + typeStr = generateAnnotatedTypeForExport home typeVars tipe + export = " " <> nameStr <> ": " <> typeStr <> ";\n" + in + if B.toLazyByteString acc == "" + then export + else acc <> export + else + acc + + +-- Check if a name should be exported (filter out internal functions) +shouldExportName :: Name.Name -> Bool +shouldExportName name = + let nameStr = Name.toChars name + in not (List.isPrefixOf "w3_" nameStr) -- Filter out wire3 encode/decode functions + + +addCtorExports :: Map.Map Opt.Global Opt.Node -> ModuleName.Canonical -> B.Builder -> Name.Name -> I.Union -> B.Builder +addCtorExports graph home acc unionName union = + case union of + I.PrivateUnion _ -> acc + I.OpenUnion (Can.Union vars cs _ _) -> + Map.foldl' (addCtorExport graph home unionName vars) acc (ctorsToMap cs) + I.ClosedUnion (Can.Union vars cs _ _) -> + Map.foldl' (addCtorExport graph home unionName vars) acc (ctorsToMap cs) + + +ctorsToMap :: [Can.Ctor] -> Map.Map Name.Name Can.Ctor +ctorsToMap ctors = + Map.fromList [(name, ctor) | ctor@(Can.Ctor name _ _ _) <- ctors] + + +addCtorExport :: Map.Map Opt.Global Opt.Node -> ModuleName.Canonical -> Name.Name -> [Name.Name] -> B.Builder -> Can.Ctor -> B.Builder +addCtorExport graph home unionName typeVars acc (Can.Ctor ctorName index numAlts args) = + let + nameStr = Name.toBuilder ctorName + -- For constructors, use namespace syntax + ModuleName.Canonical _ moduleName = home + fullTypeName = Name.toBuilder moduleName <> "." <> Name.toBuilder unionName + -- Add type parameters to the result type if needed + fullTypeNameWithParams = if null typeVars + then fullTypeName + else if null args + -- For constant constructors of generic types, use 'any' for type parameters + then fullTypeName <> "<" <> joinWith ", " (replicate (length typeVars) "any") <> ">" + else fullTypeName <> "<" <> joinWith ", " (map Name.toBuilder typeVars) <> ">" + ctorType = generateCtorTypeForExport home fullTypeNameWithParams typeVars args + export = " " <> nameStr <> ": " <> ctorType <> ";\n" + in + if B.toLazyByteString acc == "" + then export + else acc <> export + + +generateCtorTypeForExport :: ModuleName.Canonical -> B.Builder -> [Name.Name] -> [Can.Type] -> B.Builder +generateCtorTypeForExport currentModule fullTypeName typeVars args = + if null args + then + -- For constant constructors, we need to handle generics differently + -- If there are type vars, the constant needs to work for any type + if null typeVars + then fullTypeName + else fullTypeName -- For now, use the concrete type without parameters for constants + else generateCtorFunctionTypeForExport currentModule fullTypeName typeVars args + +generateCtorFunctionTypeForExport :: ModuleName.Canonical -> B.Builder -> [Name.Name] -> [Can.Type] -> B.Builder +generateCtorFunctionTypeForExport currentModule resultType typeVars args = + let + genericParams = if null typeVars + then "" + else "<" <> joinWith ", " (map Name.toBuilder typeVars) <> ">" + argTypes = map (generateTypeForExport currentModule Map.empty) args + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + -- For generic constructors, put type params before the function + directSig = genericParams <> "(" <> directParams <> ") => " <> resultType + currySig = generateCurriedSignature argTypes resultType + in + if null typeVars + then "(" <> directSig <> ") & { curry: " <> currySig <> " }" + else directSig -- For generic constructors, omit curry for now to simplify + +generateCtorType :: B.Builder -> [Name.Name] -> [Can.Type] -> B.Builder +generateCtorType fullTypeName typeVars args = + if null args + then + -- For constant constructors, we need to handle generics differently + -- If there are type vars, the constant needs to work for any type + if null typeVars + then fullTypeName + else fullTypeName -- For now, use the concrete type without parameters for constants + else generateCtorFunctionType fullTypeName typeVars args + + +generateCtorFunctionType :: B.Builder -> [Name.Name] -> [Can.Type] -> B.Builder +generateCtorFunctionType resultType typeVars args = + let + genericParams = if null typeVars + then "" + else "<" <> joinWith ", " (map Name.toBuilder typeVars) <> ">" + argTypes = map (generateType Map.empty) args + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + -- For generic constructors, put type params before the function + directSig = genericParams <> "(" <> directParams <> ") => " <> resultType + currySig = generateCurriedSignature argTypes resultType + in + if null typeVars + then "(" <> directSig <> ") & { curry: " <> currySig <> " }" + else directSig -- For generic constructors, omit curry for now to simplify + + +-- TYPE GENERATION + +generateAnnotatedTypeForExport :: ModuleName.Canonical -> [Name.Name] -> Can.Type -> B.Builder +generateAnnotatedTypeForExport currentModule typeVars tipe = + case tipe of + Can.TLambda _ _ -> + -- For functions, pass the type vars to the function generator + generateFunctionTypeWithGenericsForExport currentModule typeVars tipe + _ -> + -- For non-functions, just generate the type normally + generateTypeForExport currentModule Map.empty tipe + +generateAnnotatedType :: [Name.Name] -> Can.Type -> B.Builder +generateAnnotatedType typeVars tipe = + case tipe of + Can.TLambda _ _ -> + -- For functions, pass the type vars to the function generator + generateFunctionTypeWithGenerics typeVars tipe + _ -> + -- For non-functions, just generate the type normally + generateType Map.empty tipe + +generateTypeForExport :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Can.Type -> B.Builder +generateTypeForExport currentModule typeVarMap tipe = + case tipe of + Can.TLambda arg result -> + generateFunctionTypeForExport currentModule typeVarMap tipe + + Can.TVar name -> + Name.toBuilder name + + Can.TType home name args -> + generateNamedTypeForExport currentModule home name args typeVarMap + + Can.TRecord fields Nothing -> + generateRecordTypeForExport currentModule typeVarMap fields + + Can.TRecord fields (Just ext) -> + generateExtensibleRecordTypeForExport currentModule typeVarMap ext fields + + Can.TUnit -> + "null" + + Can.TTuple a b Nothing -> + "[" <> generateTypeForExport currentModule typeVarMap a <> ", " <> generateTypeForExport currentModule typeVarMap b <> "]" + + Can.TTuple a b (Just c) -> + "[" <> generateTypeForExport currentModule typeVarMap a <> ", " <> generateTypeForExport currentModule typeVarMap b <> ", " <> generateTypeForExport currentModule typeVarMap c <> "]" + + Can.TAlias _ _ _ (Can.Filled resolved) -> + generateTypeForExport currentModule typeVarMap resolved + + Can.TAlias home name args _ -> + generateNamedTypeForExport currentModule home name (map snd args) typeVarMap + +generateType :: Map.Map Name.Name Name.Name -> Can.Type -> B.Builder +generateType typeVarMap tipe = + case tipe of + Can.TLambda arg result -> + generateFunctionType typeVarMap tipe + + Can.TVar name -> + Name.toBuilder name + + Can.TType home name args -> + generateNamedType home name args typeVarMap + + Can.TRecord fields Nothing -> + generateRecordType typeVarMap fields + + Can.TRecord fields (Just ext) -> + generateExtensibleRecordType typeVarMap ext fields + + Can.TUnit -> + "null" + + Can.TTuple a b Nothing -> + "[" <> generateType typeVarMap a <> ", " <> generateType typeVarMap b <> "]" + + Can.TTuple a b (Just c) -> + "[" <> generateType typeVarMap a <> ", " <> generateType typeVarMap b <> ", " <> generateType typeVarMap c <> "]" + + Can.TAlias _ _ _ (Can.Filled resolved) -> + generateType typeVarMap resolved + + Can.TAlias home name args _ -> + generateNamedType home name (map snd args) typeVarMap + + +generateFunctionTypeWithGenericsForExport :: ModuleName.Canonical -> [Name.Name] -> Can.Type -> B.Builder +generateFunctionTypeWithGenericsForExport currentModule typeVars tipe = + let + genericParams = if null typeVars + then "" + else "<" <> joinWith ", " (map Name.toBuilder typeVars) <> ">" + (args, result) = collectFunctionArgs tipe + argTypes = map (generateTypeForExport currentModule Map.empty) args + resultType = generateTypeForExport currentModule Map.empty result + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + in + -- For generic functions, omit curry to keep things simple + genericParams <> "(" <> directParams <> ") => " <> resultType + +generateFunctionTypeWithGenerics :: [Name.Name] -> Can.Type -> B.Builder +generateFunctionTypeWithGenerics typeVars tipe = + let + genericParams = if null typeVars + then "" + else "<" <> joinWith ", " (map Name.toBuilder typeVars) <> ">" + (args, result) = collectFunctionArgs tipe + argTypes = map (generateType Map.empty) args + resultType = generateType Map.empty result + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + in + -- For generic functions, omit curry to keep things simple + genericParams <> "(" <> directParams <> ") => " <> resultType + +generateFunctionTypeForExport :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Can.Type -> B.Builder +generateFunctionTypeForExport currentModule typeVarMap tipe = + let + (args, result) = collectFunctionArgs tipe + argTypes = map (generateTypeForExport currentModule typeVarMap) args + resultType = generateTypeForExport currentModule typeVarMap result + in + if length args > 1 + then + -- For multi-arg functions, use intersection type + let + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + directSig = "(" <> directParams <> ") => " <> resultType + currySig = generateCurriedSignature argTypes resultType + in + "(" <> directSig <> ") & { curry: " <> currySig <> " }" + else + let + paramName = if null args then "" else "arg0: " <> head argTypes + in + "(" <> paramName <> ") => " <> resultType + +generateFunctionType :: Map.Map Name.Name Name.Name -> Can.Type -> B.Builder +generateFunctionType typeVarMap tipe = + let + (args, result) = collectFunctionArgs tipe + argTypes = map (generateType typeVarMap) args + resultType = generateType typeVarMap result + in + if length args > 1 + then + -- For multi-arg functions, use intersection type + let + paramNames = map (\i -> "arg" <> B.intDec i) [0..length args - 1] + directParams = joinWith ", " (zipWith (\name typ -> name <> ": " <> typ) paramNames argTypes) + directSig = "(" <> directParams <> ") => " <> resultType + currySig = generateCurriedSignature argTypes resultType + in + "(" <> directSig <> ") & { curry: " <> currySig <> " }" + else + let + paramName = if null args then "" else "arg0: " <> head argTypes + in + "(" <> paramName <> ") => " <> resultType + + +collectFunctionArgs :: Can.Type -> ([Can.Type], Can.Type) +collectFunctionArgs tipe = + case tipe of + Can.TLambda arg rest -> + let (args, result) = collectFunctionArgs rest + in (arg : args, result) + _ -> + ([], tipe) + + +generateCurriedSignature :: [B.Builder] -> B.Builder -> B.Builder +generateCurriedSignature [] result = result +generateCurriedSignature (arg:args) result = + -- Always use proper parameter syntax for curry + "(arg: " <> arg <> ") => " <> generateCurriedSignature args result + + +generateNamedTypeForExport :: ModuleName.Canonical -> ModuleName.Canonical -> Name.Name -> [Can.Type] -> Map.Map Name.Name Name.Name -> B.Builder +generateNamedTypeForExport currentModule home@(ModuleName.Canonical pkg moduleName) name args typeVarMap = + let + argTypes = if null args + then "" + else "<" <> joinWith ", " (map (generateTypeForExport currentModule typeVarMap) args) <> ">" + in + if isBuiltinType home name + then generateBuiltinType name argTypes + else if pkg == Pkg.core && moduleName == Name.string && name == Name.string + then "string" -- Special case for String.String + else if pkg == Pkg.core && moduleName == Name.list && name == Name.list + then "Array" <> argTypes -- Special case for List.List -> Array with type params + else if home == currentModule + then + -- When referencing types from the same module, use namespace syntax + let ModuleName.Canonical _ currentModuleName = currentModule + in Name.toBuilder currentModuleName <> "." <> Name.toBuilder name <> argTypes + else + let modulePrefix = if pkg == Pkg.core && moduleName == Name.basics + then "" + -- Avoid dots in type names for TypeScript + else Name.toBuilder moduleName <> "_" + in modulePrefix <> Name.toBuilder name <> argTypes + +generateNamedType :: ModuleName.Canonical -> Name.Name -> [Can.Type] -> Map.Map Name.Name Name.Name -> B.Builder +generateNamedType home@(ModuleName.Canonical pkg moduleName) name args typeVarMap = + let + argTypes = if null args + then "" + else "<" <> joinWith ", " (map (generateType typeVarMap) args) <> ">" + in + if isBuiltinType home name + then generateBuiltinType name argTypes + else if pkg == Pkg.core && moduleName == Name.string && name == Name.string + then "string" -- Special case for String.String + else if pkg == Pkg.core && moduleName == Name.list && name == Name.list + then "Array" <> argTypes -- Special case for List.List -> Array with type params + else + let modulePrefix = if pkg == Pkg.core && moduleName == Name.basics + then "" + -- Avoid dots in type names for TypeScript + else Name.toBuilder moduleName <> "_" + in modulePrefix <> Name.toBuilder name <> argTypes + + +isBuiltinType :: ModuleName.Canonical -> Name.Name -> Bool +isBuiltinType (ModuleName.Canonical pkg moduleName) name = + pkg == Pkg.core && moduleName == Name.basics && + (name `elem` [Name.int, Name.float, Name.bool, Name.string, Name.list]) + + +generateBuiltinType :: Name.Name -> B.Builder -> B.Builder +generateBuiltinType name typeArgs + | name == Name.int = "number" + | name == Name.float = "number" + | name == Name.bool = "boolean" + | name == Name.string = "string" + | name == Name.list = "Array" <> typeArgs + | otherwise = Name.toBuilder name <> typeArgs + + +generateRecordTypeForExport :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateRecordTypeForExport currentModule typeVarMap fields = + let + fieldList = Map.toList fields + fieldDecls = map (generateRecordFieldForExport currentModule typeVarMap) fieldList + in + "{ " <> joinWith "; " fieldDecls <> " }" + +generateRecordFieldForExport :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> (Name.Name, Can.FieldType) -> B.Builder +generateRecordFieldForExport currentModule typeVarMap (name, Can.FieldType _ tipe) = + Name.toBuilder name <> ": " <> generateTypeForExport currentModule typeVarMap tipe + +generateExtensibleRecordTypeForExport :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateExtensibleRecordTypeForExport currentModule typeVarMap ext fields = + Name.toBuilder ext <> " & " <> generateRecordTypeForExport currentModule typeVarMap fields + +generateRecordType :: Map.Map Name.Name Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateRecordType typeVarMap fields = + let + fieldList = Map.toList fields + fieldDecls = map (generateRecordField typeVarMap) fieldList + in + "{ " <> joinWith "; " fieldDecls <> " }" + + +generateRecordField :: Map.Map Name.Name Name.Name -> (Name.Name, Can.FieldType) -> B.Builder +generateRecordField typeVarMap (name, Can.FieldType _ tipe) = + Name.toBuilder name <> ": " <> generateType typeVarMap tipe + + +generateExtensibleRecordType :: Map.Map Name.Name Name.Name -> Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateExtensibleRecordType typeVarMap ext fields = + Name.toBuilder ext <> " & " <> generateRecordType typeVarMap fields + + +-- Namespace type generation (for types inside namespace declarations) +generateTypeForNamespace :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Can.Type -> B.Builder +generateTypeForNamespace currentModule typeVarMap tipe = + case tipe of + Can.TLambda arg result -> + generateFunctionType typeVarMap tipe + + Can.TVar name -> + Name.toBuilder name + + Can.TType home name args -> + generateNamedTypeForNamespace currentModule home name args typeVarMap + + Can.TRecord fields Nothing -> + generateRecordTypeForNamespace currentModule typeVarMap fields + + Can.TRecord fields (Just ext) -> + generateExtensibleRecordTypeForNamespace currentModule typeVarMap ext fields + + Can.TUnit -> + "null" + + Can.TTuple a b Nothing -> + "[" <> generateTypeForNamespace currentModule typeVarMap a <> ", " <> generateTypeForNamespace currentModule typeVarMap b <> "]" + + Can.TTuple a b (Just c) -> + "[" <> generateTypeForNamespace currentModule typeVarMap a <> ", " <> generateTypeForNamespace currentModule typeVarMap b <> ", " <> generateTypeForNamespace currentModule typeVarMap c <> "]" + + Can.TAlias _ _ _ (Can.Filled resolved) -> + generateTypeForNamespace currentModule typeVarMap resolved + + Can.TAlias home name args _ -> + generateNamedTypeForNamespace currentModule home name (map snd args) typeVarMap + +generateNamedTypeForNamespace :: ModuleName.Canonical -> ModuleName.Canonical -> Name.Name -> [Can.Type] -> Map.Map Name.Name Name.Name -> B.Builder +generateNamedTypeForNamespace currentModule home@(ModuleName.Canonical pkg moduleName) name args typeVarMap = + let + argTypes = if null args + then "" + else "<" <> joinWith ", " (map (generateTypeForNamespace currentModule typeVarMap) args) <> ">" + in + if isBuiltinType home name + then generateBuiltinType name argTypes + else if pkg == Pkg.core && moduleName == Name.string && name == Name.string + then "string" -- Special case for String.String + else if pkg == Pkg.core && moduleName == Name.list && name == Name.list + then "Array" <> argTypes -- Special case for List.List -> Array with type params + else if home == currentModule + then + -- When referencing types from the same module within namespace, use direct name + Name.toBuilder name <> argTypes + else + let modulePrefix = if pkg == Pkg.core && moduleName == Name.basics + then "" + -- Avoid dots in type names for TypeScript + else Name.toBuilder moduleName <> "_" + in modulePrefix <> Name.toBuilder name <> argTypes + +generateRecordTypeForNamespace :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateRecordTypeForNamespace currentModule typeVarMap fields = + let + fieldList = Map.toList fields + fieldDecls = map (generateRecordFieldForNamespace currentModule typeVarMap) fieldList + in + "{ " <> joinWith "; " fieldDecls <> " }" + +generateRecordFieldForNamespace :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> (Name.Name, Can.FieldType) -> B.Builder +generateRecordFieldForNamespace currentModule typeVarMap (name, Can.FieldType _ tipe) = + Name.toBuilder name <> ": " <> generateTypeForNamespace currentModule typeVarMap tipe + +generateExtensibleRecordTypeForNamespace :: ModuleName.Canonical -> Map.Map Name.Name Name.Name -> Name.Name -> Map.Map Name.Name Can.FieldType -> B.Builder +generateExtensibleRecordTypeForNamespace currentModule typeVarMap ext fields = + Name.toBuilder ext <> " & " <> generateRecordTypeForNamespace currentModule typeVarMap fields + +-- HELPERS + +joinWith :: B.Builder -> [B.Builder] -> B.Builder +joinWith sep builders = + mconcat (List.intersperse sep builders) \ No newline at end of file diff --git a/elm.cabal b/elm.cabal index 8e2221d76..4c486b1d0 100644 --- a/elm.cabal +++ b/elm.cabal @@ -172,6 +172,7 @@ Executable lamdera Generate.JavaScript.Functions Generate.JavaScript.Name Generate.Mode + Generate.TypeScript Nitpick.Debug Nitpick.PatternMatches Optimize.Case @@ -318,6 +319,7 @@ Executable lamdera Test.Wire Test.JsOutput Test.WebGL + Test.TypeScript Test.Lamdera.Evergreen.TestMigrationHarness Test.Lamdera.Evergreen.TestMigrationGenerator diff --git a/extra/Lamdera.hs b/extra/Lamdera.hs index febbb35b8..0319ce392 100644 --- a/extra/Lamdera.hs +++ b/extra/Lamdera.hs @@ -45,6 +45,9 @@ module Lamdera , useLongNames_ , enableLongNames , useLongNames + , enableExportAllFunctions + , isExportAllFunctionsEnabled + , isExportAllFunctionsEnabled_ , isTest , isLiveMode , setLiveMode @@ -460,6 +463,25 @@ enableLongNames = do modifyMVar_ useLongNames_ (\_ -> pure True) +{-# NOINLINE exportAllFunctions_ #-} +exportAllFunctions_ :: MVar Bool +exportAllFunctions_ = unsafePerformIO $ newMVar False + +{-# NOINLINE isExportAllFunctionsEnabled #-} +isExportAllFunctionsEnabled :: IO Bool +isExportAllFunctionsEnabled = do + readMVar exportAllFunctions_ + +{-# NOINLINE isExportAllFunctionsEnabled_ #-} +isExportAllFunctionsEnabled_ :: Bool +isExportAllFunctionsEnabled_ = unsafePerformIO $ isExportAllFunctionsEnabled + +enableExportAllFunctions :: IO () +enableExportAllFunctions = do + debug $ "📤 enableExportAllFunctions" + modifyMVar_ exportAllFunctions_ (\_ -> pure True) + + isTest :: IO Bool isTest = do debugM <- lookupEnv "LTEST" diff --git a/extra/Lamdera/CLI/Check.hs b/extra/Lamdera/CLI/Check.hs index f5782227f..92c320c48 100644 --- a/extra/Lamdera/CLI/Check.hs +++ b/extra/Lamdera/CLI/Check.hs @@ -599,6 +599,7 @@ buildProductionJsFiles root inProduction_ versionInfo = do , _docs = Nothing , _noWire = False , _optimizeLegible = True + , _experimentalJsTsExports = False } Make.run ["src" "LFR.elm"] $ @@ -610,6 +611,7 @@ buildProductionJsFiles root inProduction_ versionInfo = do , _docs = Nothing , _noWire = False , _optimizeLegible = False + , _experimentalJsTsExports = False } Lamdera.AppConfig.writeUsage @@ -713,6 +715,7 @@ migrationCheck root nextVersion changedTypes = do , _docs = Nothing , _noWire = False , _optimizeLegible = False + , _experimentalJsTsExports = False } -- @TODO this is because the migrationCheck does weird terminal stuff that mangles the display... how to fix this? diff --git a/extra/Lamdera/Compile.hs b/extra/Lamdera/Compile.hs index 4dba942a6..0ad0fb92c 100644 --- a/extra/Lamdera/Compile.hs +++ b/extra/Lamdera/Compile.hs @@ -42,6 +42,7 @@ makeOptimizedWithCleanup cleanup root path = do , _docs = Nothing , _noWire = False , _optimizeLegible = False + , _experimentalJsTsExports = False } wait r remove tmp @@ -69,6 +70,7 @@ make_ root = do , _docs = Nothing , _noWire = False , _optimizeLegible = True + , _experimentalJsTsExports = False } wait r -- The compilation process ends by printing to terminal in a way that overwrites @@ -98,6 +100,7 @@ makeDev root paths = do , _docs = Nothing , _noWire = False , _optimizeLegible = False + , _experimentalJsTsExports = False } wait r -- The compilation process ends by printing to terminal in a way that overwrites @@ -134,6 +137,7 @@ makeHarnessDevJs root = do , _docs = Nothing , _noWire = False , _optimizeLegible = False + , _experimentalJsTsExports = False } wait r remove tmp diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 000000000..16b62d9a9 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/schalk/git/compiler +echo "Test.all" | timeout 600 stack ghci 2>&1 \ No newline at end of file diff --git a/terminal/src/Main.hs b/terminal/src/Main.hs index c5f48c04a..e5d27327b 100644 --- a/terminal/src/Main.hs +++ b/terminal/src/Main.hs @@ -212,6 +212,7 @@ make = |-- flag "docs" Make.docsFile "Generate a JSON file of documentation for a package. Eventually it will be possible to preview docs with `reactor` because it is quite hard to deal with these JSON files directly." |-- onOff "no-wire" "Explicitly disable Lamdera's wire codegen." |-- onOff "optimize-legible" "Same as --optimize but without identifier shortening, handy for debugging optimised code or for when identifiers are more useful than smaller JS compilations." + |-- onOff "experimental-js-ts-exports" "EXPERIMENTAL: Export all exposed module functions for direct JavaScript/TypeScript interop. Functions will be available as ModuleName.functionName with both curried and direct versions." in Terminal.Command "make" Uncommon details example (zeroOrMore elmFile) makeFlags Make.run diff --git a/terminal/src/Make.hs b/terminal/src/Make.hs index bd41f2856..437015f92 100644 --- a/terminal/src/Make.hs +++ b/terminal/src/Make.hs @@ -17,6 +17,7 @@ import qualified Data.Maybe as Maybe import qualified Data.NonEmptyList as NE import qualified System.Directory as Dir import qualified System.FilePath as FP +import qualified System.IO import qualified AST.Optimized as Opt import qualified BackgroundWriter as BW @@ -48,6 +49,7 @@ data Flags = , _docs :: Maybe FilePath , _noWire :: Bool -- @LAMDERA , _optimizeLegible :: Bool -- @LAMDERA + , _experimentalJsTsExports :: Bool -- @LAMDERA - Export all exposed functions for JS/TS interop } @@ -69,11 +71,12 @@ type Task a = Task.Task Exit.Make a run :: [FilePath] -> Flags -> IO () -run paths flags@(Flags _ _ _ report _ noWire optimizeLegible) = +run paths flags@(Flags _ _ _ report _ noWire optimizeLegible experimentalJsTsExports) = do style <- getStyle report maybeRoot <- Stuff.findRoot Lamdera.onlyWhen noWire Lamdera.disableWire Lamdera.onlyWhen optimizeLegible Lamdera.enableLongNames + Lamdera.onlyWhen experimentalJsTsExports Lamdera.enableExportAllFunctions Reporting.attemptWithStyle style Exit.makeToReport $ case maybeRoot of Just root -> runHelp root paths style flags @@ -81,7 +84,7 @@ run paths flags@(Flags _ _ _ report _ noWire optimizeLegible) = runHelp :: FilePath -> [FilePath] -> Reporting.Style -> Flags -> IO (Either Exit.Make ()) -runHelp root paths style (Flags debug optimize maybeOutput _ maybeDocs _ optimizeLegible) = +runHelp root paths style (Flags debug optimize maybeOutput _ maybeDocs _ optimizeLegible experimentalJsTsExports) = BW.withScope $ \scope -> Stuff.withRootLock root $ Task.run $ do desiredMode <- getMode debug (optimize || optimizeLegible) @@ -111,13 +114,21 @@ runHelp root paths style (Flags debug optimize maybeOutput _ maybeDocs _ optimiz return () Just (JS target) -> - case getNoMains artifacts of - [] -> - do builder <- toBuilder root details desiredMode artifacts - generate style target builder (Build.getRootNames artifacts) - - name:names -> - Task.throw (Exit.MakeNonMainFilesIntoJavaScript name names) + if experimentalJsTsExports + then do -- Generate with TypeScript when experimental JS/TS exports is enabled + (jsBuilder, tsBuilder) <- toBuilderWithTypeScript root details desiredMode artifacts True + generate style target jsBuilder (Build.getRootNames artifacts) + -- Generate TypeScript declarations alongside JS + let tsTarget = if target == "-" then "-" else FP.replaceExtension target "d.ts" + generateTypeScript style tsTarget tsBuilder (Build.getRootNames artifacts) + else + case getNoMains artifacts of + [] -> + do builder <- toBuilder root details desiredMode artifacts + generate style target builder (Build.getRootNames artifacts) + + name:names -> + Task.throw (Exit.MakeNonMainFilesIntoJavaScript name names) Just (Html target) -> do name <- hasOneMain artifacts @@ -252,9 +263,13 @@ getNoMain modules root = generate :: Reporting.Style -> FilePath -> B.Builder -> NE.List ModuleName.Raw -> Task () generate style target builder names = Task.io $ - do Dir.createDirectoryIfMissing True (FP.takeDirectory target) - File.writeBuilder target builder - Reporting.reportGenerate style names target + if target == "-" then + do B.hPutBuilder System.IO.stdout builder + return () -- No reporting when outputting to stdout + else + do Dir.createDirectoryIfMissing True (FP.takeDirectory target) + File.writeBuilder target builder + Reporting.reportGenerate style names target @@ -272,6 +287,35 @@ toBuilder root details desiredMode artifacts = Dev -> Generate.dev root details artifacts Prod -> Generate.prod root details artifacts +toBuilderWithExportFlag :: FilePath -> Details.Details -> DesiredMode -> Build.Artifacts -> Bool -> Task B.Builder +toBuilderWithExportFlag root details desiredMode artifacts experimentalJsTsExports = + Task.mapError Exit.MakeBadGenerate $ + case desiredMode of + Debug -> Generate.debug root details artifacts -- TODO: Add export flag support + Dev -> Generate.devWithExportFlag root details artifacts experimentalJsTsExports + Prod -> Generate.prod root details artifacts -- TODO: Add export flag support + + +toBuilderWithTypeScript :: FilePath -> Details.Details -> DesiredMode -> Build.Artifacts -> Bool -> Task (B.Builder, B.Builder) +toBuilderWithTypeScript root details desiredMode artifacts experimentalJsTsExports = + Task.mapError Exit.MakeBadGenerate $ + case desiredMode of + Debug -> Generate.debugWithTypeScript root details artifacts experimentalJsTsExports + Dev -> Generate.devWithTypeScript root details artifacts experimentalJsTsExports + Prod -> Generate.prodWithTypeScript root details artifacts experimentalJsTsExports + + +generateTypeScript :: Reporting.Style -> FilePath -> B.Builder -> NE.List ModuleName.Raw -> Task () +generateTypeScript style target builder names = + Task.io $ + if target == "-" then + -- Skip TypeScript generation when outputting to stdout + return () + else + do Dir.createDirectoryIfMissing True (FP.takeDirectory target) + File.writeBuilder target builder + -- No special reporting for .d.ts files, they're generated alongside .js + -- PARSERS @@ -295,7 +339,7 @@ output = , _plural = "output files" , _parser = parseOutput , _suggest = \_ -> return [] - , _examples = \_ -> return [ "elm.js", "index.html", "/dev/null" ] + , _examples = \_ -> return [ "elm.js", "index.html", "/dev/null", "-" ] } @@ -304,6 +348,7 @@ parseOutput name | isDevNull name = Just DevNull | hasExt ".html" name = Just (Html name) | hasExt ".js" name = Just (JS name) + | name == "-" = Just (JS "-") | otherwise = Nothing @@ -334,7 +379,7 @@ isDevNull name = -- Clone of run that uses attemptWithStyle_cleanup run_cleanup :: IO () -> [FilePath] -> Flags -> IO () -run_cleanup cleanup paths flags@(Flags _ _ _ report _ noWire optimizeLegible) = +run_cleanup cleanup paths flags@(Flags _ _ _ report _ noWire optimizeLegible _) = do style <- getStyle report maybeRoot <- Stuff.findRoot Lamdera.onlyWhen noWire Lamdera.disableWire diff --git a/test/Test.hs b/test/Test.hs index da3b524a1..ecdc1a68e 100644 --- a/test/Test.hs +++ b/test/Test.hs @@ -21,6 +21,7 @@ import qualified Test.Ext.ElmPages.Check import qualified Test.TypeHashes import qualified Test.JsOutput import qualified Test.WebGL +import qualified Test.TypeScript import qualified Test.Lamdera.Evergreen.TestMigrationHarness import qualified Test.Lamdera.Evergreen.TestMigrationGenerator @@ -159,4 +160,5 @@ allTests = , scope "Lamdera.Evergreen.TestMigrationGenerator -> " $ Test.Lamdera.Evergreen.TestMigrationGenerator.suite , scope "Test.WebGL -> " $ Test.WebGL.suite , scope "Test.JsOutput -> " $ Test.JsOutput.suite + , scope "Test.TypeScript -> " $ Test.TypeScript.suite ] diff --git a/test/Test/Export/ComplexModule.elm b/test/Test/Export/ComplexModule.elm new file mode 100644 index 000000000..5fe803ed8 --- /dev/null +++ b/test/Test/Export/ComplexModule.elm @@ -0,0 +1,169 @@ +module ComplexModule exposing (..) + +-- Custom types (discriminated unions) +type Color + = Red + | Green + | Blue + | RGB Int Int Int + +type Maybe a + = Just a + | Nothing + +type Result error value + = Ok value + | Err error + +type Tree a + = Leaf + | Node a (Tree a) (Tree a) + +-- Type aliases +type alias User = + { id : Int + , name : String + , email : String + , isActive : Bool + } + +type alias Point = + { x : Float + , y : Float + } + +type alias Config = + { debug : Bool + , apiUrl : String + , timeout : Int + , endpoints : List String + } + +-- Functions with various signatures +identity : a -> a +identity x = x + +map : (a -> b) -> List a -> List b +map f list = + case list of + [] -> [] + x :: xs -> f x :: map f xs + +fold : (a -> b -> b) -> b -> List a -> b +fold func acc list = + case list of + [] -> acc + x :: xs -> fold func (func x acc) xs + +-- Functions returning tuples +getCoordinates : Point -> (Float, Float) +getCoordinates point = (point.x, point.y) + +split : List a -> (List a, List a) +split list = + let + len = List.length list + half = len // 2 + in + (List.take half list, List.drop half list) + +-- Functions with records +createUser : String -> String -> User +createUser name email = + { id = 0 + , name = name + , email = email + , isActive = True + } + +updateUserEmail : String -> User -> User +updateUserEmail newEmail user = + { user | email = newEmail } + +-- Functions with custom types +colorToString : Color -> String +colorToString color = + case color of + Red -> "red" + Green -> "green" + Blue -> "blue" + RGB r g b -> "rgb(" ++ String.fromInt r ++ "," ++ String.fromInt g ++ "," ++ String.fromInt b ++ ")" + +parseColor : String -> Maybe Color +parseColor str = + case str of + "red" -> Just Red + "green" -> Just Green + "blue" -> Just Blue + _ -> Nothing + +-- Complex nested types +type alias TodoItem = + { id : Int + , title : String + , completed : Bool + , tags : List String + } + +type alias TodoList = + { name : String + , items : List TodoItem + , owner : User + } + +-- Functions with nested records +createTodoList : String -> User -> TodoList +createTodoList name owner = + { name = name + , items = [] + , owner = owner + } + +addTodo : String -> List String -> TodoList -> TodoList +addTodo title tags todoList = + let + newItem = + { id = List.length todoList.items + , title = title + , completed = False + , tags = tags + } + in + { todoList | items = todoList.items ++ [ newItem ] } + +-- Higher order functions +compose : (b -> c) -> (a -> b) -> (a -> c) +compose g f x = g (f x) + +flip : (a -> b -> c) -> (b -> a -> c) +flip f x y = f y x + +curry : ((a, b) -> c) -> a -> b -> c +curry f x y = f (x, y) + +uncurry : (a -> b -> c) -> (a, b) -> c +uncurry f (x, y) = f x y + +-- Functions with multiple type parameters +zip : List a -> List b -> List (a, b) +zip listA listB = + case (listA, listB) of + ([], _) -> [] + (_, []) -> [] + (a :: restA, b :: restB) -> (a, b) :: zip restA restB + +-- Nested custom types +type Status + = Active User + | Inactive { reason : String, since : String } + | Pending (Maybe String) + +getStatusMessage : Status -> String +getStatusMessage status = + case status of + Active user -> user.name ++ " is active" + Inactive record -> "Inactive since " ++ record.since ++ ": " ++ record.reason + Pending maybeReason -> + case maybeReason of + Just reason -> "Pending: " ++ reason + Nothing -> "Pending" \ No newline at end of file diff --git a/test/Test/Export/HttpExample.elm b/test/Test/Export/HttpExample.elm new file mode 100644 index 000000000..731604e31 --- /dev/null +++ b/test/Test/Export/HttpExample.elm @@ -0,0 +1,32 @@ +module HttpExample exposing (fetchUser, parseUser, User) + +import Http +import Json.Decode as Decode + +type alias User = + { id : Int + , name : String + , email : String + } + +userDecoder : Decode.Decoder User +userDecoder = + Decode.map3 User + (Decode.field "id" Decode.int) + (Decode.field "name" Decode.string) + (Decode.field "email" Decode.string) + +fetchUser : Int -> Cmd Msg +fetchUser userId = + Http.get + { url = "https://api.example.com/users/" ++ String.fromInt userId + , expect = Http.expectJson GotUser userDecoder + } + +parseUser : String -> Result String User +parseUser json = + case Decode.decodeString userDecoder json of + Ok user -> Ok user + Err error -> Err (Decode.errorToString error) + +type Msg = GotUser (Result Http.Error User) \ No newline at end of file diff --git a/test/Test/Export/JsonExample.elm b/test/Test/Export/JsonExample.elm new file mode 100644 index 000000000..84e1e0963 --- /dev/null +++ b/test/Test/Export/JsonExample.elm @@ -0,0 +1,35 @@ +module JsonExample exposing (parseUser, encodeUser, User) + +import Json.Decode as Decode +import Json.Encode as Encode + +type alias User = + { id : Int + , name : String + , email : String + } + +parseUser : String -> Result String User +parseUser json = + case Decode.decodeString userDecoder json of + Ok user -> Ok user + Err error -> Err (Decode.errorToString error) + +userDecoder : Decode.Decoder User +userDecoder = + Decode.map3 User + (Decode.field "id" Decode.int) + (Decode.field "name" Decode.string) + (Decode.field "email" Decode.string) + +encodeUser : User -> String +encodeUser user = + Encode.encode 0 (userEncoder user) + +userEncoder : User -> Encode.Value +userEncoder user = + Encode.object + [ ("id", Encode.int user.id) + , ("name", Encode.string user.name) + , ("email", Encode.string user.email) + ] \ No newline at end of file diff --git a/test/Test/Export/TestModule.elm b/test/Test/Export/TestModule.elm new file mode 100644 index 000000000..a7294c9aa --- /dev/null +++ b/test/Test/Export/TestModule.elm @@ -0,0 +1,17 @@ +module TestModule exposing (add, greet, processUser, User) + +type alias User = + { name : String + , age : Int + } + +add : Int -> Int -> Int +add x y = x + y + +greet : String -> String -> String +greet firstName lastName = + "Hello, " ++ firstName ++ " " ++ lastName ++ "!" + +processUser : User -> String +processUser user = + user.name ++ " is " ++ String.fromInt user.age ++ " years old" \ No newline at end of file diff --git a/test/Test/Export/elm.json b/test/Test/Export/elm.json new file mode 100644 index 000000000..1e33f5d28 --- /dev/null +++ b/test/Test/Export/elm.json @@ -0,0 +1,24 @@ +{ + "type": "application", + "source-directories": [ + "." + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0" + }, + "indirect": { + "elm/json": "1.1.3", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.3" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} \ No newline at end of file diff --git a/test/Test/JsOutput.hs b/test/Test/JsOutput.hs index c055ef250..fc61b3834 100644 --- a/test/Test/JsOutput.hs +++ b/test/Test/JsOutput.hs @@ -36,6 +36,7 @@ suite = , _docs = Nothing , _noWire = True , _optimizeLegible = False + , _experimentalJsTsExports = False } fileContents <- readUtf8Text $ elmStuff ++ "/tmp.js" @@ -74,6 +75,7 @@ suite = , _docs = Nothing , _noWire = True , _optimizeLegible = False + , _experimentalJsTsExports = False } fileContents <- readUtf8Text $ elmStuff ++ "/tmp.js" @@ -123,6 +125,7 @@ suite = , _docs = Nothing , _noWire = True , _optimizeLegible = False + , _experimentalJsTsExports = False } fileContents <- readUtf8Text $ elmStuff ++ "/tmp.js" @@ -174,6 +177,7 @@ suite = , _docs = Nothing , _noWire = True , _optimizeLegible = False + , _experimentalJsTsExports = False } fileContents <- readUtf8Text $ elmStuff ++ "/tmp.js" diff --git a/test/Test/TypeScript.hs b/test/Test/TypeScript.hs new file mode 100644 index 000000000..7b6596fe8 --- /dev/null +++ b/test/Test/TypeScript.hs @@ -0,0 +1,341 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} + +module Test.TypeScript where + +import Lamdera +import EasyTest +import Test.Helpers + +import qualified Data.List as List +import qualified Data.Text as Text +import qualified Data.Text.IO as Text +import qualified System.Process as Process +import qualified System.Exit as Exit +import qualified System.Directory as Dir +import System.FilePath (()) +import Control.Monad (forM_, when) +import qualified System.Random as Random + +import qualified Init +import Make (Flags(..)) +import qualified Make +import qualified Ext.Common +import qualified Lamdera.Relative + +-- Property-based test that generates random Elm modules +-- and verifies their TypeScript declarations compile +suite :: Test () +suite = + tests + [ scope "simple TypeScript generation test" $ do + -- Test with a known simple module first + let simpleModule = Text.unlines + [ "module TestModule exposing (..)" + , "" + , "type alias User = { name : String, age : Int }" + , "" + , "type Color = Red | Green | Blue" + , "" + , "greet : String -> String" + , "greet name = \"Hello, \" ++ name" + , "" + , "addOne : Int -> Int" + , "addOne x = x + 1" + ] + + project <- io $ do + tmpDir <- Dir.getTemporaryDirectory + timestamp <- Random.randomRIO (1000000, 9999999) :: IO Int + let projectDir = tmpDir ("elm-ts-simple-" ++ show timestamp) + Dir.createDirectoryIfMissing True projectDir + pure projectDir + + let elmHome = project "elm-home" + elmStuff = project "elm-stuff" + srcDir = project "src" + modulePath = srcDir "TestModule.elm" + jsOutput = project "output.js" + dtsOutput = project "output.d.ts" + + success <- io $ do + -- Create src directory and write module + Dir.createDirectoryIfMissing True srcDir + writeUtf8 modulePath simpleModule + + -- Initialize elm.json + Test.Helpers.withElmHome elmHome $ + Ext.Common.withProjectRoot project $ + Init.init + + -- Compile with --export-all-functions + Test.Helpers.withElmHome elmHome $ + Ext.Common.withProjectRoot project $ + Make.run ["src/TestModule.elm"] $ + Make.Flags + { _debug = False + , _optimize = False + , _output = Just (Make.JS jsOutput) + , _report = Nothing + , _docs = Nothing + , _noWire = True + , _optimizeLegible = False + , _experimentalJsTsExports = True + } + + -- Check if .d.ts was generated + dtsExists <- Dir.doesFileExist dtsOutput + if not dtsExists + then do + rmdir project + pure False + else do + -- Read and verify content + maybeDtsContent <- readUtf8Text dtsOutput + + case maybeDtsContent of + Nothing -> do + rmdir project + pure False + Just dtsContent -> do + -- Basic checks + let hasNamespace = Text.isInfixOf "export declare namespace TestModule" dtsContent + hasUserType = Text.isInfixOf "export type User" dtsContent + hasColorType = Text.isInfixOf "export type Color" dtsContent + hasGreetFunc = Text.isInfixOf "greet:" dtsContent + hasAddOneFunc = Text.isInfixOf "addOne:" dtsContent + + -- Clean up + rmdir project + + pure $ hasNamespace && hasUserType && hasColorType && hasGreetFunc && hasAddOneFunc + + expect success + + , scope "property: generated TypeScript declarations compile" $ do + -- Run multiple random tests + forM_ [1..5] $ \i -> do + scope ("iteration " ++ show i) $ do + -- Generate a random module + elmModule <- io $ generateRandomElmModule + + -- Create a temporary project directory + project <- io $ do + tmpDir <- Dir.getTemporaryDirectory + timestamp <- Random.randomRIO (1000000, 9999999) :: IO Int + let projectDir = tmpDir ("elm-ts-test-" ++ show timestamp) + Dir.createDirectoryIfMissing True projectDir + pure projectDir + + let elmHome = project "elm-home" + elmStuff = project "elm-stuff" + srcDir = project "src" + modulePath = srcDir "TestModule.elm" + jsOutput = project "output.js" + dtsOutput = project "output.d.ts" + + success <- io $ do + -- Clean up any existing directories + rmdir elmHome + rmdir elmStuff + rmdir srcDir + + -- Create src directory and write module + Dir.createDirectoryIfMissing True srcDir + writeUtf8 modulePath elmModule + + -- Initialize elm.json + Test.Helpers.withElmHome elmHome $ + Ext.Common.withProjectRoot project $ + Init.init + + -- Compile with --export-all-functions + Test.Helpers.withElmHome elmHome $ + Ext.Common.withProjectRoot project $ + Make.run ["src/TestModule.elm"] $ + Make.Flags + { _debug = False + , _optimize = False + , _output = Just (Make.JS jsOutput) + , _report = Nothing + , _docs = Nothing + , _noWire = True + , _optimizeLegible = False + , _experimentalJsTsExports = True + } + + -- Check if .d.ts was generated + dtsExists <- Dir.doesFileExist dtsOutput + if not dtsExists + then do + rmdir project + pure False + else do + -- Verify TypeScript compilation + -- Create a minimal tsconfig.json + let tsConfig = "{ \"compilerOptions\": { \"noEmit\": true, \"strict\": true } }" + writeUtf8 (project "tsconfig.json") tsConfig + + -- Create a minimal package.json to avoid npx warnings + let packageJson = "{ \"name\": \"test\", \"version\": \"1.0.0\" }" + writeUtf8 (project "package.json") packageJson + + -- Run tsc directly if available, otherwise use npx + tscExists <- Dir.findExecutable "tsc" + (exitCode, _, stderr) <- case tscExists of + Just tscPath -> Process.readProcessWithExitCode + tscPath + ["--noEmit", dtsOutput] + "" + Nothing -> Process.readProcessWithExitCode + "npx" + ["--yes", "typescript@latest", "tsc", "--noEmit", dtsOutput] + "" + + -- Clean up + rmdir project + + case exitCode of + Exit.ExitSuccess -> pure True + Exit.ExitFailure _ -> do + -- For debugging + putStrLn $ "\nGenerated Elm module:" + Text.putStrLn elmModule + putStrLn $ "\nTypeScript compilation failed: " ++ stderr + pure False + + expect success + ] + +-- Generate a random valid Elm module with limited complexity +generateRandomElmModule :: IO Text.Text +generateRandomElmModule = do + -- Generate random module components + moduleName <- pure "TestModule" + + -- Generate random type definitions + numTypes <- randomInt 0 3 + typeDecls <- mapM generateTypeDecl [1..numTypes] + + -- Generate random functions + numFunctions <- randomInt 1 5 + functionDecls <- mapM generateFunctionDecl [1..numFunctions] + + -- Build the module + let moduleText = Text.unlines $ + [ "module " <> moduleName <> " exposing (..)" + , "" + , "-- Auto-generated test module" + , "" + ] ++ typeDecls ++ [""] ++ functionDecls + + pure moduleText + +-- Generate a random type declaration +generateTypeDecl :: Int -> IO Text.Text +generateTypeDecl n = do + typeKind <- randomInt 1 3 + case typeKind of + 1 -> generateTypeAlias n + 2 -> generateCustomType n + _ -> generateRecordAlias n + +-- Generate a type alias +generateTypeAlias :: Int -> IO Text.Text +generateTypeAlias n = do + baseType <- randomBaseType + pure $ "type alias Type" <> Text.pack (show n) <> " = " <> baseType + +-- Generate a custom type (ADT) +generateCustomType :: Int -> IO Text.Text +generateCustomType n = do + numConstructors <- randomInt 1 3 + constructors <- mapM (generateConstructor n) [1..numConstructors] + pure $ "type CustomType" <> Text.pack (show n) <> " = " <> + Text.intercalate " | " constructors + +-- Generate a constructor +generateConstructor :: Int -> Int -> IO Text.Text +generateConstructor typeNum ctorNum = do + numArgs <- randomInt 0 2 + if numArgs == 0 + then pure $ "Ctor" <> Text.pack (show typeNum) <> "_" <> Text.pack (show ctorNum) + else do + args <- mapM (const randomBaseType) [1..numArgs] + pure $ "Ctor" <> Text.pack (show typeNum) <> "_" <> Text.pack (show ctorNum) <> + " " <> Text.intercalate " " args + +-- Generate a record type alias +generateRecordAlias :: Int -> IO Text.Text +generateRecordAlias n = do + numFields <- randomInt 1 3 + fields <- mapM generateRecordField [1..numFields] + pure $ "type alias Record" <> Text.pack (show n) <> " = { " <> + Text.intercalate ", " fields <> " }" + +-- Generate a record field +generateRecordField :: Int -> IO Text.Text +generateRecordField n = do + fieldType <- randomBaseType + pure $ "field" <> Text.pack (show n) <> " : " <> fieldType + +-- Generate a function declaration +generateFunctionDecl :: Int -> IO Text.Text +generateFunctionDecl n = do + numArgs <- randomInt 0 3 + resultType <- randomBaseType + + let funcName = "func" <> Text.pack (show n) + args = map (\i -> "arg" <> Text.pack (show i)) [1..numArgs] + argTypes = replicate numArgs "Int" + + typeSignature = funcName <> " : " <> + Text.intercalate " -> " (argTypes ++ [resultType]) + + implementation = if numArgs == 0 + then funcName <> " = " <> defaultValueForType resultType + else funcName <> " " <> Text.intercalate " " args <> + " = " <> defaultValueForType resultType + + pure $ Text.unlines [typeSignature, implementation] + +-- Generate a random base type +randomBaseType :: IO Text.Text +randomBaseType = do + typeChoice <- randomInt 1 6 + case typeChoice of + 1 -> pure "Int" + 2 -> pure "String" + 3 -> pure "Bool" + 4 -> pure "Float" + 5 -> do + innerType <- randomSimpleType + pure $ "List " <> innerType + _ -> do + innerType <- randomSimpleType + pure $ "Maybe " <> innerType + +-- Generate a simple type (no nested generics) +randomSimpleType :: IO Text.Text +randomSimpleType = do + typeChoice <- randomInt 1 4 + case typeChoice of + 1 -> pure "Int" + 2 -> pure "String" + 3 -> pure "Bool" + _ -> pure "Float" + +-- Get default value for a type +defaultValueForType :: Text.Text -> Text.Text +defaultValueForType t + | t == "Int" = "0" + | t == "Float" = "0.0" + | t == "String" = "\"\"" + | t == "Bool" = "False" + | Text.isPrefixOf "List" t = "[]" + | Text.isPrefixOf "Maybe" t = "Nothing" + | otherwise = "0" -- fallback + +-- Generate a random integer in range +randomInt :: Int -> Int -> IO Int +randomInt minVal maxVal = Random.randomRIO (minVal, maxVal) \ No newline at end of file diff --git a/test/scenario-elm-pages-incompatible-wire/.elm-pages/Pages.elm b/test/scenario-elm-pages-incompatible-wire/.elm-pages/Pages.elm index 8c12b0d11..29f601777 100644 --- a/test/scenario-elm-pages-incompatible-wire/.elm-pages/Pages.elm +++ b/test/scenario-elm-pages-incompatible-wire/.elm-pages/Pages.elm @@ -7,4 +7,4 @@ import Json.Encode builtAt : Time.Posix builtAt = - Time.millisToPosix 1688292895462 + Time.millisToPosix 1754226512725 diff --git a/tools/tree-shake/README.md b/tools/tree-shake/README.md new file mode 100644 index 000000000..a74c769f5 --- /dev/null +++ b/tools/tree-shake/README.md @@ -0,0 +1,56 @@ +# Elm Tree Shake + +A dead code elimination tool for Elm's JavaScript output when using the `--export-all-functions` flag. + +## Overview + +When compiling Elm modules with `--export-all-functions` for JavaScript/TypeScript interop, the entire Elm runtime (~28KB) is included even for simple modules. This tool removes unused runtime functions, achieving 50-96% size reduction depending on the module complexity. + +## Installation + +```bash +cd tools/tree-shake +npm install +``` + +## Usage + +### Using the wrapper script (recommended) + +```bash +./elm-compile-tree-shake.sh Module.elm output.js +``` + +This script: +1. Compiles the Elm module with `--export-all-functions` +2. Pipes the output through the tree shaker +3. Produces minimal JavaScript with only used runtime functions + +### Manual pipeline + +```bash +# Compile to stdout and pipe through tree shaker +lamdera make Module.elm --export-all-functions --output=- 2>/dev/null | tail -n +2 | node elm-tree-shake.js - output.js + +# Or use existing compiled output +node elm-tree-shake.js input.js output.js +``` + +## How it works + +1. Parses the JavaScript AST using Acorn +2. Identifies all function declarations in the Elm runtime +3. Finds entry points (exported functions) +4. Builds a dependency graph of function usage +5. Extracts only transitively used functions +6. Reconstructs minimal output preserving the IIFE structure + +## Results + +- **Simple modules**: ~96% reduction (27KB → 1KB) +- **Complex modules**: ~58% reduction (44KB → 18KB) + +The tool preserves: +- Only runtime functions actually used (F2, F3, List operations, etc.) +- Module exports for CommonJS/ES modules +- TypeScript compatibility \ No newline at end of file diff --git a/tools/tree-shake/elm-compile-tree-shake.sh b/tools/tree-shake/elm-compile-tree-shake.sh new file mode 100755 index 000000000..a987beccd --- /dev/null +++ b/tools/tree-shake/elm-compile-tree-shake.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Usage: elm-compile-tree-shake.sh + +if [ $# -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +ELM_FILE="$1" +OUTPUT_FILE="$2" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +COMPILER_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMPILER="$COMPILER_DIR/.stack-work/install/x86_64-linux-tinfo6/c15ff6a5d083c6e061a1d54e6c3841c8be647aecbdae79225f229a5189eed1b3/9.2.8/bin/lamdera" +TREE_SHAKER="$SCRIPT_DIR/elm-tree-shake.js" + +# Compile and tree shake in a pipeline +# Use tail to skip the "Success!" line +"$COMPILER" make "$ELM_FILE" --export-all-functions --output=- 2>/dev/null | tail -n +2 | node "$TREE_SHAKER" - "$OUTPUT_FILE" \ No newline at end of file diff --git a/tools/tree-shake/elm-tree-shake-stdin.js b/tools/tree-shake/elm-tree-shake-stdin.js new file mode 100755 index 000000000..0d5dc5f4b --- /dev/null +++ b/tools/tree-shake/elm-tree-shake-stdin.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +// Simple wrapper to collect all stdin before processing +const fs = require('fs'); + +let chunks = []; +process.stdin.on('data', chunk => chunks.push(chunk)); +process.stdin.on('end', () => { + let input = Buffer.concat(chunks).toString(); + + // Skip "Success!" line if present + if (input.trim().startsWith('Success!')) { + const lines = input.split('\n'); + lines.shift(); // Remove first line + input = lines.join('\n'); + } + + const ElmTreeShaker = require('./elm-tree-shake.js'); + + try { + const shaker = new ElmTreeShaker(input); + const minimalCode = shaker.analyze(); + + if (process.argv[2]) { + fs.writeFileSync(process.argv[2], minimalCode); + + const originalSize = Buffer.byteLength(input); + const minimalSize = Buffer.byteLength(minimalCode); + const reduction = ((originalSize - minimalSize) / originalSize * 100).toFixed(1); + + console.error(`Original: ${(originalSize / 1024).toFixed(1)}KB`); + console.error(`Minimal: ${(minimalSize / 1024).toFixed(1)}KB`); + console.error(`Reduction: ${reduction}%`); + } else { + process.stdout.write(minimalCode); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +}); \ No newline at end of file diff --git a/tools/tree-shake/elm-tree-shake.js b/tools/tree-shake/elm-tree-shake.js new file mode 100755 index 000000000..5f442fb27 --- /dev/null +++ b/tools/tree-shake/elm-tree-shake.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +const acorn = require('acorn'); +const walk = require('acorn-walk'); +const escodegen = require('escodegen'); +const fs = require('fs'); + +class ElmTreeShaker { + constructor(code) { + this.code = code; + this.ast = acorn.parse(code, { ecmaVersion: 2020 }); + this.usedIdentifiers = new Set(); + this.functionDeclarations = new Map(); + this.variableDeclarations = new Map(); + this.dependencies = new Map(); + } + + analyze() { + // Step 1: Extract all function and variable declarations + this.extractDeclarations(); + + // Step 2: Find entry points (exports and _Platform_export) + this.findEntryPoints(); + + // Step 3: Build dependency graph + this.buildDependencyGraph(); + + // Step 4: Collect all transitively used identifiers + this.collectTransitiveDependencies(); + + // Step 5: Generate minimal code + return this.generateMinimalCode(); + } + + extractDeclarations() { + walk.simple(this.ast, { + FunctionDeclaration: (node) => { + if (node.id && node.id.name) { + this.functionDeclarations.set(node.id.name, node); + this.dependencies.set(node.id.name, new Set()); + } + }, + VariableDeclarator: (node) => { + if (node.id && node.id.name && node.init) { + this.variableDeclarations.set(node.id.name, node); + this.dependencies.set(node.id.name, new Set()); + } + }, + AssignmentExpression: (node) => { + if (node.left.type === 'Identifier') { + this.variableDeclarations.set(node.left.name, node); + this.dependencies.set(node.left.name, new Set()); + } + } + }); + } + + findEntryPoints() { + walk.simple(this.ast, { + CallExpression: (node) => { + // Look for _Platform_export calls + if (node.callee && node.callee.name === '_Platform_export') { + console.log('Found _Platform_export call'); + this.analyzeExports(node); + } + }, + MemberExpression: (node) => { + // Look for scope['Elm'] assignments + if (node.object && node.object.name === 'scope' && + node.property && node.property.value === 'Elm') { + console.log('Found scope.Elm assignment'); + this.usedIdentifiers.add('_Platform_export'); + } + }, + AssignmentExpression: (node) => { + // Look for scope['Elm'] = ... patterns + if (node.left && node.left.type === 'MemberExpression' && + node.left.object && node.left.object.name === 'scope') { + console.log('Found scope assignment'); + // Add all exports + walk.simple(node.right, { + Identifier: (idNode) => { + this.usedIdentifiers.add(idNode.name); + } + }); + } + } + }); + console.log('Entry points found:', Array.from(this.usedIdentifiers)); + } + + analyzeExports(exportCall) { + if (exportCall.arguments.length > 0) { + walk.simple(exportCall.arguments[0], { + Identifier: (node) => { + this.usedIdentifiers.add(node.name); + }, + MemberExpression: (node) => { + if (node.object.type === 'Identifier') { + this.usedIdentifiers.add(node.object.name); + } + } + }); + } + } + + buildDependencyGraph() { + // For each declaration, find what it depends on + const allDeclarations = [ + ...this.functionDeclarations.entries(), + ...this.variableDeclarations.entries() + ]; + + for (const [name, node] of allDeclarations) { + const deps = this.dependencies.get(name) || new Set(); + + // Walk the node to find all identifier references + const nodeToWalk = node.init || node.right || node; + if (nodeToWalk) { + walk.simple(nodeToWalk, { + Identifier: (idNode) => { + // Skip the declaration name itself + if (idNode.name === name) return; + + // This is a dependency + if (this.functionDeclarations.has(idNode.name) || this.variableDeclarations.has(idNode.name)) { + deps.add(idNode.name); + } + } + }); + } + + this.dependencies.set(name, deps); + } + } + + collectTransitiveDependencies() { + const visited = new Set(); + const toVisit = [...this.usedIdentifiers]; + + while (toVisit.length > 0) { + const current = toVisit.pop(); + if (visited.has(current)) continue; + + visited.add(current); + + const deps = this.dependencies.get(current); + if (deps) { + for (const dep of deps) { + if (!visited.has(dep)) { + toVisit.push(dep); + } + } + } + } + + this.usedIdentifiers = visited; + } + + generateMinimalCode() { + // Find the IIFE wrapper + let iifeNode = null; + walk.simple(this.ast, { + CallExpression: (node) => { + if (node.callee.type === 'FunctionExpression' && + node.arguments.length === 1 && + node.arguments[0].type === 'ThisExpression') { + iifeNode = node; + } + } + }); + + if (!iifeNode) { + throw new Error('Could not find IIFE wrapper'); + } + + // Filter the body of the IIFE to include only used declarations + const functionBody = iifeNode.callee.body; + const filteredStatements = []; + + for (const statement of functionBody.body) { + if (this.shouldIncludeStatement(statement)) { + filteredStatements.push(statement); + } + } + + // Create new minimal IIFE + const minimalIife = { + type: 'CallExpression', + callee: { + type: 'FunctionExpression', + params: iifeNode.callee.params, + body: { + type: 'BlockStatement', + body: filteredStatements + } + }, + arguments: iifeNode.arguments + }; + + // Generate code + return escodegen.generate({ + type: 'Program', + body: [{ + type: 'ExpressionStatement', + expression: minimalIife + }] + }, { + format: { + indent: { + style: ' ' + } + } + }); + } + + shouldIncludeStatement(statement) { + // Always include 'use strict' + if (statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + statement.expression.value === 'use strict') { + return true; + } + + // Check function declarations + if (statement.type === 'FunctionDeclaration' && statement.id) { + return this.usedIdentifiers.has(statement.id.name); + } + + // Check variable declarations + if (statement.type === 'VariableDeclaration') { + return statement.declarations.some(decl => + decl.id && decl.id.name && this.usedIdentifiers.has(decl.id.name) + ); + } + + // Check expression statements (assignments, calls) + if (statement.type === 'ExpressionStatement') { + // Platform export calls + if (statement.expression.type === 'CallExpression' && + statement.expression.callee.name === '_Platform_export') { + return true; + } + + // Assignments + if (statement.expression.type === 'AssignmentExpression' && + statement.expression.left.type === 'Identifier') { + return this.usedIdentifiers.has(statement.expression.left.name); + } + + // scope['Elm'] assignments + if (statement.expression.type === 'AssignmentExpression' && + statement.expression.left.type === 'MemberExpression' && + statement.expression.left.object.name === 'scope') { + return true; + } + } + + // Check if statements (for module.exports check) + if (statement.type === 'IfStatement') { + return true; // Keep all if statements for now (module.exports logic) + } + + return false; + } +} + +// CLI usage +if (require.main === module) { + const args = process.argv.slice(2); + if (args.length !== 2) { + console.error('Usage: elm-tree-shake.js '); + process.exit(1); + } + + const [inputFile, outputFile] = args; + + try { + let code; + if (inputFile === '-') { + // Read from stdin + code = fs.readFileSync(0, 'utf8'); + } else { + code = fs.readFileSync(inputFile, 'utf8'); + } + const shaker = new ElmTreeShaker(code); + const minimalCode = shaker.analyze(); + + fs.writeFileSync(outputFile, minimalCode); + + const originalSize = Buffer.byteLength(code); + const minimalSize = Buffer.byteLength(minimalCode); + const reduction = ((originalSize - minimalSize) / originalSize * 100).toFixed(1); + + console.log(`Original: ${(originalSize / 1024).toFixed(1)}KB`); + console.log(`Minimal: ${(minimalSize / 1024).toFixed(1)}KB`); + console.log(`Reduction: ${reduction}%`); + + // Report what was kept + console.log(`\nKept ${shaker.usedIdentifiers.size} identifiers:`); + const kept = Array.from(shaker.usedIdentifiers).sort(); + console.log(kept.filter(id => id.startsWith('F') || id.startsWith('A') || id.startsWith('_')).join(', ')); + + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +module.exports = ElmTreeShaker; \ No newline at end of file diff --git a/tools/tree-shake/package-lock.json b/tools/tree-shake/package-lock.json new file mode 100644 index 000000000..b3d072abe --- /dev/null +++ b/tools/tree-shake/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "elm-tree-shake", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "elm-tree-shake", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "acorn": "^8.11.2", + "acorn-walk": "^8.3.0", + "escodegen": "^2.1.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/tools/tree-shake/package.json b/tools/tree-shake/package.json new file mode 100644 index 000000000..29a8e1aab --- /dev/null +++ b/tools/tree-shake/package.json @@ -0,0 +1,17 @@ +{ + "name": "elm-tree-shake", + "version": "1.0.0", + "description": "Dead code elimination for Elm compiled output", + "main": "elm-tree-shake.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["elm", "tree-shake", "dead-code-elimination"], + "author": "", + "license": "ISC", + "dependencies": { + "acorn": "^8.11.2", + "acorn-walk": "^8.3.0", + "escodegen": "^2.1.0" + } +}