How IMPORT finds libraries
This page is for people who run L4 tooling or develop the standard library. For how to write an IMPORT, see IMPORT.
When an L4 file says IMPORT prelude, the toolchain has to find a file
called prelude — but on a developer's machine there may be several: one
compiled into the binary, one in each git worktree, one in a machine-global
store, one shipped inside the VSCode extension. This page explains, in order:
- the resolution order and why each tier exists
- the dev/prod playbook — what to set in each situation
- how to read the resolver's logs and warnings
- the embed-staleness gotcha (why
cabal builddoesn't pick up your prelude edit)
An as-built description of the code behind all of this lives in
specs/as-built/import-resolution.md in the repository.
The resolution order
For a bare module name <mod>, resolution tries, first match wins:
| # | Tier | Location | Who it serves |
|---|---|---|---|
| 0 | VFS | in-memory files (project:/<mod>.l4, importer-relative, root) |
web IDE / Monaco / unsaved editor buffers |
| 1 | env override | $JL4_LIBRARY_PATH/<mod>.l4 |
operators and stdlib developers taking explicit control |
| 2 | project root | <root>/<mod>.l4 |
a project that ships its own copy of a library |
| 3 | importer-relative | <dir of importing file>/<mod>.l4 |
multi-file projects; local overrides beside the code |
| 4 | embedded | compiled into the binary at build time | everyone else — this is the default stdlib |
| 5 | XDG store | ~/.local/share/jl4/libraries/<mod>.l4 |
machine-global extra libraries the embed doesn't carry |
| 6 | VSCode bundle | <exeDir>/../../libraries/<mod>.l4 |
the packaged extension, for non-embedded extras |
The XDG store is the jl4/libraries folder of the user's data directory.
It is $XDG_DATA_HOME/jl4/libraries/ when that variable is set to an absolute path.
Otherwise it is ~/.local/share/jl4/libraries/ on macOS and Linux, and %APPDATA%\jl4\libraries\ on Windows, which is usually C:\Users\<you>\AppData\Roaming\jl4\libraries\.
Every other mention of ~/.local/share/jl4/libraries/ on this page means this folder.
Three rules of thumb fall out of the table:
- Project-scoped beats embedded. If you put a
prelude.l4in your project root or beside the importing file, you get it. Overriding the stdlib is a conscious, visible act — the file is in your project. - Embedded beats ambient. A machine-global file (XDG, bundle) can never silently replace the stdlib your binary was built with. Ambient tiers only serve modules the embed does not carry.
JL4_LIBRARY_PATHis absolute. When it is set, the embedded copy is not consulted at all — the operator has taken full control of the library store. (The ambient tiers are still searched after it, for extras.)
jl4-service uses a shorter ladder. A service that runs bundles other
people uploaded cannot let its own machine stand in for the standard library.
So a deployment's IMPORTs are resolved against the bundle's own files (tier 0)
and then the standard library built into the service binary (tier 4), and
nothing else. The service reads neither JL4_LIBRARY_PATH, nor its working
directory, nor the XDG store (~/.local/share/jl4/libraries/), and not the
extension bundle either: tiers 1, 2, 3, 5 and 6 are not consulted. A
prelude.l4 in the service's working directory, or a library directory exported
into its environment, has no effect on any deployment. (Without this rule it
would: the deploy reports "applied" and every call then answers 422 sum is not in scope.) The service says at start-up, at WARN, when JL4_LIBRARY_PATH is
set and therefore ignored.
A consequence for anyone moving a deployment onto the service: a library you used to supply from the host's filesystem has to be a file in the bundle, in the same directory as the file that imports it. A file elsewhere in the bundle is found only when its directory happens to be the one the service treats as the bundle's root, the directory of the first file in the bundle's sort order, so do not rely on it.
A bundle that ships a file with the name of a standard library module, such as
its own prelude.l4, is still honoured, as any project copy is: the importers
that can see that file — those in the same directory — get it instead of the
library's. The service says so, at WARN, each time it compiles such a bundle
(Bundle file stands in for a standard library module, naming the file and the
module), unless the file is exactly the library's own text.
An IMPORT that finds nothing — no file in the bundle, no module in the built-in
library — does not stop the deploy, which still reports "applied": the file
typechecks, and only a call that reaches something the missing module should have
defined fails. The service logs a WARN for each one, naming the deployment, the
importing file and the module (Bundle IMPORT resolves to nothing), when the
bundle is compiled, which a restart does again from the stored sources. A refusal at
deploy time is not made; it would need a ruling of its own.
This ordering is pinned by tests: jl4-lsp/test/LibraryResolutionSpec.hs
(unit, table-driven), the l4 library resolution shadow (B′) group in
jl4/tests-cli/Main.hs (black-box CLI), and, for the service's shorter ladder,
jl4-service/test/StdlibResolutionSpec.hs and
jl4-service/test/UnresolvedImportSpec.hs.
The dev/prod playbook
I'm writing L4 programs (consuming the stdlib)
Do nothing. The embedded copy serves IMPORT prelude and friends, and it is
guaranteed to match the binary you're running. This is the prod regime:
an installed l4, the VSCode extension, jl4-service — none of them need any
environment setup or files on disk.
Reading the stdlib from your editor. Go-to-definition on a stdlib name (say
sum, after IMPORT prelude) opens a read-only tab named prelude.l4.
It is not a file on disk: its address is jl4-embedded:/prelude.l4, and the
language server supplies the text — the exact copy compiled into the binary, so
what you read is what your program runs. Hover and go-to-definition work inside
it. To change the stdlib, edit jl4-core/libraries/ and see the next section.
The limits: this works in the VSCode extension, which asks the server for the
text with a custom request, l4/embeddedLibrary (params {"uri": …}, reply
{"text": …}). Another editor connected to jl4-lsp receives the same
jl4-embedded: address and has to make the same request to show it. When
JL4_LIBRARY_PATH is set, the embedded copy is not consulted and
go-to-definition lands on a real file instead.
I'm developing the standard library itself (editing jl4-core/libraries/*.l4)
Pin the resolver at your worktree, in the shell where you build and test:
export JL4_LIBRARY_PATH="$PWD/jl4-core/libraries"
This is the reliable way to see your stdlib edits take effect, because of
the embed-staleness gotcha below: a plain
cabal build does not refresh the embedded copy, so without the pin your
freshly-edited prelude loses to the stale embed on every bare IMPORT.
Do not try to accomplish this with symlinks in
~/.local/share/jl4/libraries/ pointing into a checkout. That was the old
convenience and it is what this ordering guards against: with several
worktrees, a machine-global symlink points at one of them and silently lies
to all the others. It also no longer works for stdlib modules, because the
embedded copy outranks the XDG store; the env var is both safer and the only
supported mechanism.
I'm running the test suites
- CI exports
JL4_LIBRARY_PATHalready. jl4-test(the golden suite) sets it for you if unset (pointing at the jl4-core datadir), so a barecabal test jl4-testworks. Locally you still want the worktree pin from the previous section so the goldens run against the sources you're editing.l4-cli-test: the library-resolution cases manufacture their own environment per case (a fabricatedXDG_DATA_HOME, the variable dropped), so they need nothing set. The rest of the suite runs in the environment it is given.
I'm packaging / deploying
The embedded copy is the source of truth for a deployed binary. The VSCode
extension additionally ships libraries/*.l4 next to the bundled binary
(tier 6); since the embed already carries the full stdlib, that directory only
matters for libraries not in the embed. (Whether it can be dropped entirely
is an open question — see §6 in specs/as-built/import-resolution.md.)
jl4-service consults neither: see
the shorter ladder above.
I want machine-global extra libraries
~/.local/share/jl4/libraries/ is still searched — below the embed. Use it
for third-party or personal libraries that are not part of the stdlib. Do not
use it to shadow stdlib modules; it can't anymore, and if a differing copy of
any module sits there you'll get a warning (next section).
Reading the resolver's output
All of the following appears only when JL4_LIBRARY_PATH is unset (in the
env-pinned regime you have explicitly chosen your store, and the logs keep the
historical one-line form).
The winner, at Info priority, with its position in the candidate order and — when the path is a symlink — its real target:
Info | [Import Resolution] Found in embedded libraries (candidate 3 of 5): prelude
Info | [Import Resolution] Found on filesystem (candidate 1 of 5): /work/prelude.l4
(The first when nothing in the project shadows the stdlib, the second when the
project root holds its own prelude.l4.)
The full candidate table, at Debug priority:
Debug | [Import Resolution] Candidate order for prelude: [1] project root: /work/prelude.l4 (miss); [2] importer-relative: /work/sub/prelude.l4 (miss); [3] embedded stdlib (compiled into this binary) (hit); [4] XDG data dir: /home/dev/.local/share/jl4/libraries/prelude.l4 (miss); [5] VSCode bundle: … (miss)
The shadow warning, at Warning priority — the one you must never ignore. It fires (once per session per configuration) when more than one copy of a module is visible and their contents differ; byte-identical copies stay silent. Symlinks are dereferenced so you can see which checkout an entry really points at:
Warning | [Import Resolution] Multiple differing copies of module `prelude` are visible:
[chosen] embedded stdlib (compiled into this binary)
[shadowed] XDG data dir: /home/dev/.local/share/jl4/libraries/prelude.l4 -> /home/dev/src/other-checkout/jl4-core/libraries/prelude.l4
The [chosen] copy is used wherever this module is imported. To use a
different copy, set JL4_LIBRARY_PATH or place prelude.l4 in the project root or beside the importing file.
The CLI (l4 check, l4 run, …) prints Warning-level resolution lines to
stderr; Info/Debug lines are visible in the LSP log channel only.
l4 check --json and l4 run --json carry the same Warning lines in the
diagnostics array of their output, so a program that reads that array sees a
shadow warning there too.
An IMPORT that resolves to nothing is announced once, at Warning priority, as
Module not found: myhelpers: once for each importing file, however many of the
toolchain's rules go looking for the import. (l4 ast, which never
typechecks, prints that one line on stderr and exits 0.) The diagnostic that
accompanies it is described under Module not found.
When two tiers name the same file — the CLI sets the project root to the
importing file's own directory, so the root tier and the importer-relative tier
probe one path — the shadow warning lists that file once, not as both
[chosen] and [shadowed].
Debugging "my edit didn't take effect":
- Run
l4 checkon the importing file and read stderr. A shadow warning tells you immediately that two differing copies exist and which one won. - No warning? Then only one copy is visible — check the winner line in the language server's log to see which one, and remember the embed-staleness gotcha: the embedded copy may be older than your checkout.
- When in doubt,
export JL4_LIBRARY_PATH="$PWD/jl4-core/libraries"and re-run. If behaviour changes, you were not loading the file you thought.
The embed-staleness gotcha
The embedded stdlib is captured at build time by a Template Haskell splice
(L4.API.EmbeddedLibraries), which reads libraries/*.l4 while GHC compiles
that module. For a checkout it reads the checkout's own jl4-core/libraries/
(the dependency GHC records is <checkout>/jl4-core/libraries/prelude.l4, with
a content hash), so GHC itself would re-embed a library that changed. The catch
is that Cabal does not ask it to.
Measured on 2026-10-13, a checkout built with cabal build (GHC 9.10.3,
Cabal 3.16):
- Appending a definition to
jl4-core/libraries/prelude.l4and runningcabal buildprintsUp to date. Nothing is rebuilt, and the binary keeps serving the old prelude.touch-ingL4.API.EmbeddedLibrarieschanges nothing either: Cabal compares content, not modification times. - The same edit plus any real change to a
jl4-coresource file (the test was a comment on an unrelated module) makes Cabal call GHC. GHC sees the new content hash, recompilesL4.API.EmbeddedLibraries, and the rebuilt binary serves the new prelude.
Consequences:
- Editing
jl4-core/libraries/prelude.l4in a worktree and running a plaincabal buildleaves the binary on the old prelude. - To force a re-embed without the pin: make a real content change to a
jl4-coresource file, or clean-rebuildjl4-core. - Which is why the supported dev workflow is the
JL4_LIBRARY_PATHpin, not "rebuild and hope". The pin outranks the embed deterministically.
This was measured for a checkout. A build from somewhere else — cabal install
from an sdist, a Nix build — reads the libraries/ of the directory it is built
in; this page does not claim to have measured those.
This is documented at the code site too
(jl4-core/src/L4/API/EmbeddedLibraries/TH.hs) and in
jl4-core/libraries/README.md.