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:

  1. the resolution order and why each tier exists
  2. the dev/prod playbook — what to set in each situation
  3. how to read the resolver's logs and warnings
  4. the embed-staleness gotcha (why cabal build doesn'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.l4 in 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_PATH is 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_PATH already.
  • jl4-test (the golden suite) sets it for you if unset (pointing at the jl4-core datadir), so a bare cabal test jl4-test works. 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 fabricated XDG_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":

  1. Run l4 check on the importing file and read stderr. A shadow warning tells you immediately that two differing copies exist and which one won.
  2. 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.
  3. 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.l4 and running cabal build prints Up to date. Nothing is rebuilt, and the binary keeps serving the old prelude. touch-ing L4.API.EmbeddedLibraries changes nothing either: Cabal compares content, not modification times.
  • The same edit plus any real change to a jl4-core source file (the test was a comment on an unrelated module) makes Cabal call GHC. GHC sees the new content hash, recompiles L4.API.EmbeddedLibraries, and the rebuilt binary serves the new prelude.

Consequences:

  • Editing jl4-core/libraries/prelude.l4 in a worktree and running a plain cabal build leaves the binary on the old prelude.
  • To force a re-embed without the pin: make a real content change to a jl4-core source file, or clean-rebuild jl4-core.
  • Which is why the supported dev workflow is the JL4_LIBRARY_PATH pin, 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.