Native Elm extension

morphir-elm-native is a Rust implementation of an Elm frontend and backend for Morphir. It reads Elm source with a vendored tree-sitter grammar and writes Morphir IR v3 or v4 natively, without migrating between the two. The same extension generates Elm back from a distribution.

It compiles type declarations only. A value declaration is not dropped silently: each one is reported as an ELM_VALUE_SKIPPED warning, and the compile still succeeds.

The extension ships as morphir-elm-binding, both as a Rust library and as a WebAssembly guest that the daemon installs and runs through MEP.

What it advertises

Field Value
Extension id morphir-elm-native
Name Morphir Elm (native)
Language elm, file extension .elm
Backend target elm
IR versions 3 and 4
Incremental yes
Fragments no

Using it

Compile an Elm package with the extension named explicitly:

morphir compile --extension morphir-elm-native

Generate Elm from a distribution with the matching target:

morphir generate --target elm

Generated artifacts are one file per module, under src/, named after the module path: My.Domain.Types is written to src/My/Domain/Types.elm.

Two Elm providers

This extension does not replace morphir-elm, the JavaScript implementation, which stays the default provider for Elm. morphir-elm covers the whole Elm language, including values; morphir-elm-native covers types and runs without a Node.js toolchain. Ask for the native one by extension id when you want it.

The prelude option

Name resolution needs to know what Int, List and Dict mean. That mapping is the prelude, chosen through the elmPrelude compile option:

  • "elm-core" is the default. It maps the Elm core modules onto the Morphir.SDK package the way morphir-elm’s IncrementalResolve does, and supplies the implicit imports Elm gives every module.
  • "none" supplies nothing. Every reference has to resolve inside the package or its declared dependencies, and an unresolved one is reported as ELM_RESOLVE_NOT_FOUND naming prelude: none.
  • An inline object declares a prelude of your own: module_alias entries map a source module path onto a platform one, and package entries declare the platform packages and the types their modules hold.

A dependency supplied in the compile request wins over a prelude package of the same name, so a real Morphir.SDK distribution shadows the built-in description of it.

The doc comment option

A {-| ... -} comment becomes the doc a Morphir document carries. How much of it survives is the elmDocComments compile option:

  • "morphir-elm" is the default. It writes what morphir-elm writes, byte for byte: the text between the delimiters, with its leading spaces and its interior and trailing newlines kept. {-| An integer.\n-} on a type becomes " An integer.\n".
  • "trimmed" takes the surrounding whitespace off, so the same comment becomes "An integer.".

Any other value is refused with an ELM_REQUEST error naming the option. An undocumented type carries "" and an undocumented module carries null in either mode.

Byte compatibility includes one morphir-elm quirk. A module doc comment loses one character more than a declaration’s does, because Morphir.Elm.ParsedModule.documentation uses String.dropRight 3 where Morphir.Elm.IncrementalFrontend uses String.dropRight 2 for a declaration. In the usual layout — -} alone on the last line — that character is the newline in front of it, which is why {-| Shared types.\n-} on a module becomes " Shared types." and not " Shared types.\n". "trimmed" trims either way and so does not reproduce it.

The mode changes the IR, so it is part of the compile context: switching it invalidates a baseline whole rather than leaving modules that were compiled under the other mode in place. It is not part of a module’s interface, so editing only a doc comment still does not recompile that module’s dependents.

The Elm backend lays a doc comment out itself ({-| <text> -}), so generating Elm from a distribution whose docs carry their own whitespace gives that whitespace a second layout. A source → IR → source → IR round trip is exact under "trimmed"; under "morphir-elm" the regenerated docs are equivalent but not character-identical.

The ordering option

elmOrdering chooses the order the document lists its modules, types and constructors in:

  • "source" is the default: everything appears in the order the source declares it. It is the default because it is the better order to read — a reader comparing a document with the Elm it came from finds things where they were written, and a diff between two versions of a package shows the edit rather than a reshuffle.
  • "morphir-elm" writes the order morphir-elm writes. morphir-elm holds modules, types and constructors in Elm Dicts, so its JSON order is the Dict’s key order. Use it when a document has to compare byte for byte against one morphir-elm produced.

Any other value is refused with an ELM_REQUEST error naming the option.

The sort is on the words a Morphir name holds, never on a rendered spelling of it: a key is a Path (List Name) or a Name (List String), compared element by element with a shorter prefix first. So ListOf (["list","of"]) sorts before Listen (["listen"]), where comparing the joined spellings would put them the other way round.

Record fields and constructor arguments are never reordered. They are positional in morphir-elm too — a list, not a Dict — so source order already matches.

The order changes the document, so it is part of the compile context: switching it invalidates a baseline rather than assembling a distribution out of modules emitted under both.

Package names and module paths

A Morphir module path is relative to its package, so the package path is stripped from an Elm module name that starts with it — the rule morphir-elm follows (Morphir.Elm.Frontend, List.drop (List.length currentPackagePath)). A package My.Package holding My.Package.Foo.Bar files the module as Foo.Bar, and a dependent that writes import My.Package.Foo.Bar finds it by that same prefix. Without the strip, a package this frontend compiles could not be imported under its natural name.

Module names the host sees keep the Elm spelling. moduleResults[].name, modules, dependsOn, exposedModules and every diagnostic name the module the way the source does; only the IR module path and the module half of a fully qualified name are relative.

Generation writes the package path back on: a module whose IR path is Foo.Bar in a package My.Package is generated as src/My/Package/Foo/Bar.elm, holding module My.Package.Foo.Bar. The rule is symmetric, so what this extension generates it compiles again into the very same distribution. A package whose modules were never named after it pays for that once: local/example holding a module Example generates src/Local/Example/Example.elm with module Local.Example.Example — a different name from the one compiled, which then round-trips unchanged.

Which modules are public

A request states its public modules in exposedModules, the way morphir.json does: package-relative, so a package My.Pkg exposes My.Pkg.Aliases by writing Aliases. An entry written out in full is understood too, and entries are matched on the words a Morphir name keeps, not on the letters typed. Omitting exposedModules exposes every module; an empty list exposes none.

A module that is not listed is still published when an exposed module reaches into it: if a public type of an exposed module names a type of an unexposed one, that module becomes public, and so does anything the type that opened it names in turn. This is morphir-elm’s rule (Morphir.Elm.IncrementalFrontend.collectImplicitlyExposedModules), and it exists because an exposed module may not describe its types in terms nobody outside the package can name. Only what a module actually publishes counts: a private type’s body, and an opaque custom type’s constructor arguments, expose nothing.

Differences from morphir-elm

These are deliberate, not gaps. Declaration order is not among them: it is a default, and elmOrdering switches it.

  • Values are skipped. This frontend compiles type declarations only. Each value declaration is reported as an ELM_VALUE_SKIPPED warning and the compile still succeeds, so a distribution written here has no values.
  • Implicit exposure follows every declaration, not every module. morphir-elm stops walking at the module: a reference into a module it has already published is dropped without being followed (Morphir.Elm.IncrementalFrontend.elm:1250-1252). So if an exposed module publishes two types of one private module, only the first one reached has its own references followed, and a module the second names stays private while a public type points into it. This frontend follows every declaration it reaches, which is the only way the result is internally consistent. It can only publish more modules than morphir-elm, never fewer.
  • An unreachable private module is kept. morphir-elm drops a module that is neither exposed nor reached from an exposed one (Repo.removeUnusedModules). This frontend writes it into the distribution as Private instead: the request named it, so the document describes it.
  • Colliding module paths are refused. Two Elm module names can write the same IR module path once the package prefix is stripped from each — Foo and My.Foo under package My both become Foo. That is reported as an ELM_REQUEST error naming both modules and both uris, and the later document fails, rather than one module silently replacing the other in the document.

Incremental compilation

The extension holds no state between calls. The host holds the baseline, and each compile decides per module whether the baseline can be reused.

A request may carry a baseline with one entry per module: its name, uri, sourceDigest, interfaceDigest, dependsOn list and module IR. Every result carries moduleResults, one entry per module in the request, with the same fields plus a status and that module’s diagnostics. A host feeds one run’s moduleResults back as the next run’s baseline.

dependsOn is every in-package module the module imports, together with every in-package module its type references resolved to. It is deliberately wider than the references alone: a module imported exposing (..) and never named today can still change what a bare name means tomorrow — adding a type to it can make a name ambiguous — so a module that recorded only what it resolved would be reused against a scope that moved underneath it. Widening to the imports is what keeps an incremental run’s answer equal to a clean run’s; the cost is recompiling a module whose unused import changed.

The compile context

A module’s compiled form depends on more than its own source. It depends on the IR version being written, on the prelude its names were resolved against, on the package the request compiles under, and on the dependency distributions the request supplied — a dependency that loses a type changes what a module resolves to without touching a byte of it, and a renamed package changes the FQNames and module keys every module’s IR is written with, without touching a byte of it either. All of that is folded into one value, the context digest, and a baseline is scoped to it.

Every result that got as far as a validated request carries a contextDigest, including a failed one. A host stores it next to the module results it keeps and echoes it back as the baseline’s contextDigest. A run reuses a baseline only when the two agree; otherwise it ignores the baseline whole and reports one ELM_REQUEST warning:

  • baseline ignored: it was built under a different compile context — the digests differ.
  • baseline ignored: it carries no contextDigest — the baseline will not say which compilation it came from, so it cannot be shown to describe this one.

Ignoring the baseline is never wrong, only slower: every module is recompiled, and the run’s answer is the one a clean run would give.

The statuses are:

Status Meaning
compiled The module was parsed, resolved and emitted in this run.
unchanged Its source and every dependency interface matched the baseline, so the baseline IR was reused.
failed The module could not be compiled; it carries the diagnostics that say why.
blocked A dependency failed and no baseline interface was available to resolve against (ELM_BLOCKED).

Two digests decide the reuse. sourceDigest is sha256 over the module text, so any edit changes it. interfaceDigest is sha256 over the canonical JSON of the module’s public interface, computed from the resolved model and independent of the IR version, so it changes only when what the module offers changes. That is the distinction that keeps a doc comment edit from recompiling dependents while retyping an exposed alias does recompile them.

A module that is neither recompiled nor reusable fails rather than resolving against a stale interface. Deleting a dependency fails its dependents with ELM_RESOLVE_NOT_FOUND, and a baseline entry this version cannot read is treated as absent, with an ELM_REQUEST warning saying so.

Editing a module’s header away is not the same as deleting the module. A document that no longer says which module it is still has a uri, and when the baseline recognises that uri the document is reported as that module, failed with its ELM_SYNTAX diagnostic. Its dependents then take the ordinary failed-dependency path — the last good interface when there is one, blocked when there is not — instead of being told a module disappeared. Without a baseline match there is no name to report, so the document is dropped with its diagnostics as before.

Diagnostics

Every diagnostic that concerns source carries a location with a uri and a zero-based UTF-16 range.

Code Reported when
ELM_SYNTAX The grammar could not parse the module.
ELM_VALUE_SKIPPED A value declaration is outside the subset. Warning; the compile still succeeds.
ELM_RESOLVE_NOT_FOUND A type reference names nothing reachable.
ELM_RESOLVE_AMBIGUOUS A bare name is offered by more than one import.
ELM_DUPLICATE_TYPE Two declarations a Morphir document cannot tell apart, such as Foo_Bar and FooBar.
ELM_IMPORT_CYCLE The modules in the request import each other in a cycle.
ELM_TYPE_CYCLE A module’s own type aliases stand for each other in a circle. A custom type may be recursive; an alias may not.
ELM_BLOCKED A dependency failed and has no baseline interface to resolve against.
ELM_REQUEST The request itself is wrong: an unsupported IR version, a foreign language id, two documents claiming one module.
ELM_IR An IR version’s reader or writer refused the document.
ELM_UNSUPPORTED A distribution holds a construct with no Elm form. It is left out, the rest of the module is still generated, and the result is not a success.

A result is a success only when it carries no Error diagnostic at all. That covers diagnostics about the request rather than about a module — an unreadable dependency distribution, say — which no per-module status would otherwise report.

Building the WebAssembly guest

The grammar is vendored C, so a wasm build needs a C compiler that targets wasm32-unknown-unknown. Install clang and llvm-ar — the release task checks for both and stops with a message naming them if either is missing — and build:

cargo build -p morphir-elm-binding --release --target wasm32-unknown-unknown

The release bundle, its descriptor and the offline install test run from one task:

mise run extension:artifact:elm-native