bundler-hs bundles a Haskell solution file and the local library modules it imports into a single file for competitive programming submissions. It handles qualified imports: A.f and B.f can coexist, renamed to fA and fB. Names that nothing competes for keep their original spelling, and --tree-shake leaves out the library code you never reach.
Run the Nix flake directly:
$ nix run github:toyboot4e/bundler-hsOr, clone the repository and run cabal install.
Pass your solution file and your library directory. The bundle is printed to stdout:
$ bundler-hs Main.hs --lib path/to/your/library > submission.hsSee bundler-hs --help for the full list of options.
Local library modules are merged into one flat namespace, and names are renamed only as far as that takes. Each file is parsed with its own imports in scope, and the bundle merges the external imports of every file.
A name keeps its original spelling when nothing in the bundle competes for it. That takes two things. Nothing may name its module, meaning no qualified import of it and no M.f written into it. Naming a module says where a name comes from, and the bundle goes on saying it, so import qualified Deque as D renames the whole module and so does a single Deque.push written under a plain import Deque (or reached through a re-export module). And nothing else may claim the name: not another library module, not your own top level, not Prelude, not one of your import lists, and not a name the bundle writes that only an external import can be providing.
import Deque -- push -> pushThat last one is how an open import gets a say. import Control.Monad.State.Class does not list what it brings in, but a bundle that writes modify without any local module defining it has to be getting it from there, so a library's own modify moves aside. And because a kept name is never one the bundle writes with an external meaning, every kept name is also hidden from the open imports the bundle carries, which settles the ones nothing happens to write:
import Data.List hiding (partition) -- `partition` here is the library'sEvery other name takes its module's suffix, and the shortest suffix that keeps the bundle collision-free wins:
- the alias of your own
qualified ... asimport, - the initials of the last component of the module name,
- that component itself,
- the whole module name, flattened.
import qualified SuffixArray as SA -- build -> buildSA
import qualified Data.Deque -- push -> pushD, else pushDeque, else pushDataDequeOperators cannot carry a suffix, so they always keep their name, which makes two library modules exporting the same operator an error, resolvable with --rename-cmd. That command is told the suffix the default rule settled on (empty for a name that keeps its spelling), so echo "$name$suffix" reproduces the default behavior.
Binding one alias to two modules is an error wherever the bundler has to rewrite the references, which means anywhere in a library module, and in your own file when a local module is one of the two. Two external modules under one alias in your own file are left alone, because your imports survive as written and GHC unions their scope.
In library modules, import Prelude hiding (…) lists are pruned when the renames make them unnecessary. A list that cannot be pruned is carried into the bundle, where it governs the whole merged module rather than the one library file that wrote it. When your own file has no Prelude import of its own, the bundle also emits a plain import Prelude so your code keeps the full implicit Prelude, and the leftover hiding stops taking effect.
Off by default. --tree-shake-lib keeps only the library declarations your code actually reaches, --tree-shake-app does the same for your own file, and --tree-shake turns on both:
$ bundler-hs Main.hs --lib path/to/your/library --tree-shake-lib > submission.hsReachability is decided before renaming, so what goes does not compete for spellings either: a dropped Util.sort leaves sort free for whoever survives.
The roots are every declaration of your own file for --tree-shake-lib. For --tree-shake-app they are your module's export list, or main alone when the file has no export list and is Main (a file with no module header is Main). Any other module without an export list exports everything it defines, so nothing of it can go.
What a declaration needs is read generously: every name written anywhere inside it counts, local binders included, so the analysis errs towards keeping code. Only declarations that never stand on their own are dropped without being named:
- an instance goes when a local class or type of its head goes, and stays when its head is entirely external (an orphan instance is always kept),
- a type signature, a fixity declaration or an
INLINEpragma goes with the binding it annotates. A pragma the bundler does not recognize is kept whenever any name it mentions survives, which can only keep code alive, never drop it.
A module that loses every declaration loses its banner, its language pragmas, and its external imports along with it. Comments written directly above a dropped declaration of your own file go with it.
Preserved CPP conditionals are analysed with every branch in play, so a declaration only one branch uses still survives. A conditional left enclosing nothing is dropped like any other empty one.
Shaking is also what keeps the hiding lists of the section above small, because a name that is not in the bundle is not hidden from anything either. On a solution against a large library, an unshaken bundle of 107 KB carried 60 KB of hiding lists, and --tree-shake-lib took the whole thing to 14 KB.
The bundle emits the union of the LANGUAGE pragmas in effect for every file, that is, each file's own pragmas plus the default-language / default-extensions of its cabal project. Conflicting combinations can still fail to compile, which the bundler cannot prevent.
CPP is handled separately from the pragma union. Directives are preserved into the bundle, in your own file and in library modules alike, with every branch of a library conditional renamed. Nothing is decided at bundle time, so the compiler that builds the bundle picks the branch just as it would have before bundling.
That keeps one bundle usable for both purposes. A local build whose cpp-options define DEBUG gets the debug branch, and a judge compiling the same file without them gets the other one:
-- in a library module, and still in the bundle
#ifdef DEBUG
debug :: Bool
debug = True
#else
debug :: Bool
debug = False
#endifA conditional left enclosing nothing is dropped, which happens when the imports or header pragmas between it were hoisted into the bundle's own import and pragma blocks.
Under --minify-lib a conditional would otherwise split the library section into a line before it and a line after it. Top-level order carries no meaning in Haskell, so the conditionals are moved to the end of the library section instead and everything else stays on one line. Unminified output leaves every declaration where it was written.
Some modules cannot be preserved. A library module is run through the preprocessor whole when:
- It uses
#define,#undef, or#include. A macro body is opaque text that the renamer cannot rewrite, so#define INNER helpersitting beside ahelperthat gets renamed would leave the expansion pointing at a name no longer there. Expansion has to come first. - Its own cabal project supplies macros through
cpp-optionsthat your project does not. Those disappear along with the package, so the branch has to be decided while they are still known. - A directive cuts through the middle of a declaration, where blanking it out would change the meaning.
The macros used for that evaluation are the ones GHC will have when it compiles the bundle. Because the bundle is a single file built inside your project, they come from your project's cpp-options, not the library's. A library project's own cpp-options only fill in macros your project says nothing about.
Conditionals in the cabal file are resolved the way a plain cabal build resolves them. os, arch, and impl(ghc) are decided against the host, and if flag(debug) follows the flag's value in the build plan: the flag's declared default, overridden by any assignment in cabal.project, then cabal.project.freeze, then cabal.project.local, later files winning. Both constraints: entries and package NAME / flags: stanzas are read, so all of these turn the flag on:
constraints: my-lib +debug
package my-lib
flags: +debug
Environment variables play no part, because they play no part for cabal either. DEBUG=1 cabal build does not define DEBUG. Only the flag does.
Use -D NAME[=VALUE] (repeatable) to supply a macro yourself. It takes precedence over both projects:
$ bundler-hs Main.hs --lib path/to/your/library -D DEBUGNote that -D only affects modules that are evaluated. A preserved conditional is the compiler's to decide, not the bundler's, so -D will not force one of its branches.
The comments and pragmas above your module header are copied into the bundle verbatim, so a banner comment or an {- ORMOLU_DISABLE -} marker survives. Pragmas picked up from the cabal defaults and the library modules are appended below them.
The output is formatted with hindent by default. Use the --format-cmd option to substitute another formatter.
ormolu does not work as expected. Because we parse the code and operate on the AST, the printed output has newlines in unusual places that ormolu does not handle well.
The generated code is not guaranteed to compile or run correctly even if your original code is correct. Make sure to test it before submitting. You may need to adjust your code so it still compiles under the merged imports and language extensions of the bundle.
Other known limitations:
- An open import (
import Data.List) is handled by hiding every unrenamed top-level name from it. That covers values, types and classes, but not data constructors, because an import list cannot name one on its own. A kept constructor that an open import also exports (Down, say) is still ambiguous. The same goes for a name arriving through aT(..)item of an import the bundler keeps as written, such asimport Control.Monad.IO.Class (MonadIO(..)), whose children are unknowable and which cannot take a hiding list either. Move the name out of the way with--rename-cmd. - Formatting is not preserved.
- Library comments are not preserved. Their CPP directives are, but their comments are not.
- A library module that uses
#define,#undef, or#includehas all of its conditionals resolved at bundle time, not just the ones that need it. There is no-Uto undefine a macro for that pass. - A
cabal.projectis only looked for from your source file up to the directory holding its.cabal. One sitting further up, as in some multi-package repositories, is not found. - Not supported (hard error):
.hs-bootfiles, the{-# SOURCE #-}pragma, and Template Haskell splices in library modules.
Inside the dev shell (direnv allow or nix develop):
$ just build # cabal build all
$ just test # golden test suite
$ just test-compile # golden suite + ghc -fno-code check of every bundle
$ just test-accept # re-record goldens after an intentional change
$ just run Main.hs --lib lib