hanki

14. Modules and imports

One file is one module. The module name is the file's basename (io.hk gives module io), and it is flat across the project: use NAME binds the module whose file stem is NAME wherever that file sits under the project root, and a subdirectory organises source for readers and appears in no import (src/util/helpers.hk is use helpers, as stdlib/core/io.hk is use io). The accepted cost is that two files sharing a stem anywhere in one project are an error naming both: universal qualification keys every item by its module's name, and they would collide at the merge whatever the importing files called them. A directory holding its own hanki.config.hk is another package and no part of this one, and a dotted path is one thing, a dependency's exported module (below). Imports come in two forms:

use io           # qualified: reach members as io.print!, io.read_line!

open option      # unqualified: Option, Some, None drop into this file's scope bare

def greet!(name: Option<string>) -> () [io]
  match name
    Some(n) -> io.print!(n)
    None -> io.print!("hello")
  end
end

hanki fmt canonicalizes each contiguous run of imports: the use statements first, then the open statements, one blank line between the two sections, no blank lines within a section, and each section sorted alphabetically by full dotted path. The sort is free to reorder opens because source order has no resolution meaning: a bare use of a name that two opens export is an error (H0306, below) and never order-resolved. A comment run between imports travels with the import that follows it, as does the run attached to the block's first import, though a run detached from the first import by a blank line remains above the block; a same-line trailing comment remains on its import. Duplicates are not deduplicated, and an import block after other items is canonicalized where it stands and never hoisted to the top of the file.

An import nothing needs is a compile error (H0625), with a machine-applicable fix deleting its line (hanki check --fix). An open is live where a bare name resolves through it, and a use where the file writes a qualified reference its binding gates. A use of a core module is therefore never needed, core being ambient-qualified (below), and the diagnostic says so. Method and trait dispatch into an import-gated module counts as use, and conn.with_tx! is enough to keep use sqlite live with no sqlite. spelled anywhere, as is a doc example. An import of a module contributing test blocks or a provide is the edge that pulls those items into the workspace, and is never reported. The trade is one-sided: the lint may leave a dead import standing, and it never deletes a live one.

use is the qualified form, and the default. It brings a module into scope under its name, and members are reached as module.Name. Use it for cross-project modules where the call-site context helps: parse.parse_command!(line), todos.Todos, geometry.Point. A qualified function or action is also a value: f = io.print! stores the action under use io, and f("hello") invokes it. The reference retains its parameter types, return type and effect row.

open is the unqualified form. It drops a module's top-level names into the importing file's scope without the module. prefix. Core stdlib modules (list, option, display, eq?, str) and small libraries that feel like part of the language are conventionally opened by user code, and they are not auto-opened. A file that wants bare Some and None must write open option; without it, only the qualified spelling (option.Some, option.None) resolves. User modules are typically left qualified.

Two opens that introduce the same bare name make that name ambiguous: a bare reference that would resolve through them is a compile error (H0306, naming both exports). open order cannot be a tiebreak, since hanki fmt sorts the open section and order would rebind the name on reformat. An open whose export collides with a module in scope is the same error and the same reasoning: use util beside an open exporting util, or the ambient core list beside an open exporting list. Adding that open would otherwise rebind every util.… already written in the file, with nothing to report at the import. Reach the opened item by its qualified name, or reach the module by dropping the open or binding it as use m as alias. A name the file itself declares is not contested: the local declaration wins, and the more-local thing never loses. Co-opening the modules remains legal, open env beside open http, which both export get!. Reach a collided name by its fully-qualified module.Name, and every name only one of them exports remains usable bare.

Both forms exist because open is convenience over a spelling that already works. That applies to effect ops too: an op has no module.op call form (§6) and is reachable through its effect's name, db.Database.exec(…) under use db, and open is not the only way to reach another module's ops. Core is ambient-qualified (below), and a qualified constructor is as legal in a pattern as in an expression, option.Some(v) -> v being a fine match arm. A constructor pattern obeys the same scope rule as any other name, and carrying arguments is no exception: Some(v) needs open option as bare None does, and one match never takes two import rules. The scrutinee's type is consulted only to choose between several in-scope variants sharing a name, sys declaring Other in four of its sums, and never to bring a name into scope, which leaves what resolves independent of how much has been inferred at that point. Hence the convention: open a module for its ops, or where its bare names are already universal (Some, None, Ok, Err), and leave the rest qualified, where the prefix tells a reader which module a name came from.

use X as n renames the module qualifier. A use may bind the module under a different file-local qualifier: use http as h, then reach its members as h.get! and h.Response. The alias is local to the file, like the import, and shadows nothing elsewhere, and the qualifier resolves as the real module would (h.Response is http.Response, and import-gating still applies to the real http). Its purpose is clash resolution, where two dotted imports would land on one qualifier (use a.model beside use b.model), and shorthand for a long name. It renames the file-local qualifier and nothing more: two modules of the same basename still collide at the merge, universal qualification keying items by module name (dotted paths, below). as is supported only on use, and open X as n is a parse error, open already bringing names in bare. as is contextual, a keyword only in this position, and it remains usable as an ordinary identifier everywhere else.

Core is ambient-qualified, and the extra tier is import-gated. The qualified spelling of a core stdlib module resolves with no import: option.Some, string.len and list.map all work with neither use nor open, and an open only adds the unqualified spelling on top. The extra tier, this section's capability and utility modules (io, fs, net, http, time, datetime, json, xml, terminal, random, env, process, path, cbor, vcs, supervisor, sqlite, log, base64, base32, hex, sha2, sh, flags, uuid, glob, parsecheck, diag, tls, url), is import-gated: a file reaches net.connect!, io.print!, or any of a module's functions, actions, effect ops, constructors and types, only after it uses or opens that module. Without the import the name is out of scope, a value reference being H0301 unknown name and a qualified type annotation (net.Conn) being H0302 unknown type, and each file declares the capability vocabulary it pulls in. Any qualified type that names nothing in scope is the same H0302, and a typo'd foo.Bar is an error in place of an accepted opaque type. The effect-row capabilities, [io], [net], [fs] and the rest, are built-in language atoms and no module members, and declaring one needs no import. io is import-gated like the rest; the "conventionally opened" note above is a style convention about open against use, and no ambient seeding. The prelude below, and the whole core tier, are ambient regardless.

A user module may not shadow a stdlib module name. use <name> for a stdlib module always binds the standard library, and a local file whose basename collides with a stdlib module, list.hk, time.hk or option.hk, can never be imported: the use resolves to the stdlib, and the local file would be dropped without a word. The workspace rejects the collision with an error in place of dropping the file; rename the local module. Adding a baked module claims a name, which costs somebody a file name. The stdlib and user code share one flat namespace, and each of the 90 baked module names is a basename no project may use, config, log, http, path, env, time, net and test among them. Adding one is therefore a source-breaking change for every project that already named a file that, and it is announced with the name it claims, on the same footing as renaming or removing one. The extra tier is where this bites, that being the tier meant to keep growing (§17). The cost is priced and not escaped: multi-segment module paths remain reserved and unimplemented (§21), and there is no local-only import form to opt out with. The claimed names are pinned in a test, and a stdlib addition cannot take one by accident.

A local binding shadows a same-named module in value position. A parameter, let or var whose name matches an in-scope module (path, list, module) shadows that module wherever a value is expected, member access included, and module.length reads the length property of the local module value and not the module stdlib module. This is ordinary lexical scoping, the innermost binding winning, the same shadowing the prelude rule below grants a file's own declarations. Without it a value reference would misdispatch to the module wherever the module happened to export a same-named member. The module is then unreachable by that name inside the binding's scope; rename the binding where you need both.

A module and a same-named type compose in member position. A stdlib file named for a builtin type, i8.hk holding impl i8 and the other type-named stdlib modules in that form, makes one name reach both a module and a type. It applies to a user file in the same form too, point.hk declaring type point, even though universal qualification keys that type point.point in place of flattening it: the resolution probes both spellings, point.origin() reaches the type's associated fn, and a name both sides supply is the same ambiguity error and no silent pick of the module's. The convention makes this rare, a PascalCase Point beside module point being two distinct spellings that raise no composition question, and a rule that chose one of two candidates without saying so would be worse than one that did not apply. A qualified i8.name call resolves against the module's exports first and, on a miss, falls through to the type's associated functions, inherent first and then static trait fns, and a module-level helper in i8.hk never hides i8.zero or i8.parse from the rest of the tree. A top-level item whose name a same-named type also supplies as an associated function is rejected at the declaration (H0623, naming the two candidates); with no error the two namespaces could disagree about what i8.name means. Rename one of them, conventionally the module-level helper, the type's surface being the public one. A local binding shadowing the name still wins over both.

The same composition applies to an effect name beside a same-named type: effect Foo and type Foo in one module, where Foo.member could mean an op or an associated function (§6). Here the probe is at the use and not at the declaration. An effect's ops and a type's associated functions are two independent surfaces that collide only member by member, the unique supplier answers, and a member both supply is H0633, again an error and no silent pick.

The prelude covers the core trait names and the container types. The exception to "nothing is auto-opened" has two parts, both lowest-priority, and a file's own same-named declaration shadows them. (1) Trait names. Eq, Ord, Add, Sub, Hash, Display, Encode and Decode are in scope unqualified everywhere with no open required, these traits' behaviour being ambient already: == and != dispatch through Eq, < > <= >= through Ord, + and - on non-numeric operands through Add and Sub, #{…} interpolation through Display, and serialization derives and .encode! / Type.decode! through Encode and Decode. Naming one in a generic bound or impl head (def sort<T: Ord>(…), impl Eq<MyType>, impl Encode<MyType>) therefore resolves to the same canonical trait and forces no open. What enters scope is those eight trait names plus Ordering and its variants Less, Equal and Greater, which makes the result of cmp matchable without open ord. Only the two serialization trait names enter from their modules: another export such as decode.max_decode_depth remains qualified or needs open decode. (2) Container types. List, Option, Result and Map resolve to their stdlib types (list.List, option.Option, result.Result, map.Map) unqualified everywhere, and a function or face that returns one, def list_dir!(…) -> List<DirEntry>, ships the real methodful type even with no open list, and any caller can use its methods. Without the prelude a bare List annotation would be a distinct, methodless head. Only the type names are ambient: the variant constructors Some, None, Ok and Err still need their module open. Write open option for bare Some. Everything else, bare stdlib functions, ops and other types, still needs an explicit open.

Universal qualification puts every top-level item kind at its module-qualified name. def, actor, struct, type including each variant constructor, trait, impl, effect and meta const all sit at option.Option, option.Some, todos.Todos, display.Display in the merged workspace. Within the defining file the bare name still works, the Rewriter splicing bare to qualified at workspace merge, and across files you need either the qualified spelling or an open declaration.

# parse.hk
use types                 # cross-file refs via types.Quit, types.Add, ...

open from_string          # the trait `i32.parse` dispatches through
open result               # bare Ok(_), Err(_) patterns

def parse_command!(line: string) -> types.Command [throws types.CommandError]
  if line == "quit"
    types.Quit
  elif line.starts_with?("done ")
    match i32.parse(line.slice_from(5))
      Ok(id)  -> types.Done(id)
      Err(_)  -> throw types.Parse(line)
    end
  ...

Names starting with _ are not exported: an open does not splat them, and a qualified reference to one is H0622 (§12).

Dotted paths reach a dependency's other modules. use <dependency>.<module> binds <module>.hk of the package the manifest binding <dependency> names, gated by that package's exports as the bare form is (§21). The qualifier is the tail, use entsoe.model then model.Doc; as renames it like any use, and open entsoe.model drops the module's names in bare. The head must be a binding declared in the importing file's own package manifest, never a stdlib module and never a sibling file, and a path is at most two segments, a binding naming a package and a package being one level deep. Two imports in one file landing on the same qualifier is an error naming both; only an as can put them there, and removing or changing it is the fix.

Note what this does not dissolve. Universal qualification keys every item by its module's basename, and two model.hk modules in one program collide at the merge whatever the importing files call them. A library's module names are part of its API, hanki api-diff keying items by module.item, and an alias is a file-local spelling and no rename of the module.

Stdlib modules are auto-injected by the compiler: use io and open option work without an io.hk or option.hk file on disk, the embedded stdlib owning them.

Dynamic loading

A module can be loaded at runtime with module.load!(path), module.unload! and module.reload!, and the path is a Hanki source file and never a pre-built artifact: the user never manages .sos. The trait T a load is checked against comes from the binding's annotation, m: Module<Greeter> = module.load!("greeter.hk"), which is the only way to supply it. The language has no call-site type argument, and there is nothing to write between load! and its parentheses. Both tiers implement this surface, and §22 records which parts each supports today. Interface matching treats bare and documented qualified native resource names as the same type, including inside collection and function types.

The boundary between an AOT host and an interpreted loaded module is observationally transparent. Calling into a loaded module is indistinguishable from single-tier execution save in time and memory, and through explicit opt-in reflection, the same parity bar the two tiers hold to each other, and nearly free here because both tiers are codegen strategies over one shared runtime core (hanki-rtcore: heap, reference-counted memory management, scheduler, mailbox, RtValue, the numeric algebra, exit and death). A mixed-tier differential test, an AOT host plus an interpreted module checked against the all-bytecode run, enforces it. Passing opaque non-data values, closures, futures and resource handles, across the boundary is a documented scoped limitation: these non-inspectable handles are not marshallable data. Runtime-resolved backends, a JIT shipped as a sidecar .so or shelling out to the toolchain, are disfavoured: they would turn "is the backend present?" into a per-call error every dynamic-module program must handle, and they break fully-static deployment.

Data crosses the boundary by deep-copy marshalling, for every data type: scalars, strings, the arbitrary-precision numerics and bytes through their RootKind, and every struct and sum aggregate, including the applied-type generics List, Map, Option and Result and user generics. A sum or struct heap object records its own layout, and the self-describing pointer walk reconstructs it with no type information. The bytecode tier's arguments are already RtValues and need no marshalling. Opaque non-data values, closures, futures, resource handles, and a Module<T> handle passed as an argument, are the documented scoped limitation.

An effectful loaded method behaves identically on both tiers, with one exception. On the bytecode tier it performs io, fs and net work and actor sends, its yields serviced by the host scheduler as inline code's are. The AOT-embedded loader services the same OS-seam host-calls (io, fs, net, env, process, clock reads, random draws) through the same intrinsic table, and a mixed-tier differential test pins an io.print!-ing loaded method's output and exit code across the two. The exception is a loaded method's actor sends, which the AOT-embedded loader does not yet service. It is the one place a loaded method is not tier-neutral, and the reason §22 lists actor operations on that loader as outstanding.

A loaded module may declare the actor it exposes. Where the host trait T's ! methods match a loaded actor's on handlers by name and signature, module.load!(path) spawns that actor and the returned Module<T> is its reference: m.method!(args) is a blocking send, the actor is live and stateful between load! and unload!, and unload! stops it. Each dispatch resolves the actor's handlers against its own loaded module, which is what module.reload! swaps over.

A per-actor migrate(old: T) hook drives state migration, and the reload is actor-aware: the in-flight handler finishes under the old code, every subsequent message runs under the new, and a layout change triggers migration, or, with no hook to run, a clean StateMigrationRequired.

A throw escaping a loaded method is reconciled into the invoking image's throw-type space by type name on both tiers, the per-module tags not agreeing across the boundary, and re-raised at the call site, and a loaded def! export's throws E surfaces at that caller's matching catch e: E. The AOT-embedded loader recovers the caller's tag from the image-scoped throw-type table registered at startup. A loaded actor handler that throws instead goes down like any actor, its throws E not crossing the send (§15), and the caller observes the death as actor.SendFailed::Died. The error type must be shared or structurally identical across the two modules. The thrown value's variant tags are type-local and therefore agree already, and they are not remapped, which makes a divergent redefinition that reorders the type's variants the documented unsupported case.