# 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](/l4/reference/libraries.md#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](#the-resolution-order)
2. [the dev/prod playbook — what to set in each situation](#the-devprod-playbook)
3. [how to read the resolver's logs and warnings](#reading-the-resolvers-output)
4. [the embed-staleness gotcha](#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 `IMPORT`s 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:

```sh
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](#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](#the-resolution-order).

### 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](/l4/reference/errors.md#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`.
