14. Modules and imports
One file = one module. The module name is the file's basename (io.hk → module io). 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
Canonical import layout. hanki fmt canonicalizes each contiguous run of imports: the use statements first, then the open statements, exactly 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 carries no resolution meaning: a bare use of a name that two opens export is an error (H0306, below), 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 stays above the block; a same-line trailing comment stays on its import. Duplicates are not deduplicated, and an import block after other items is canonicalized where it stands, never hoisted to the top of the file.
use (qualified, default). Brings a module into scope under its name; members are reached as module.Name. Use this for cross-project modules where the call-site context is helpful (parse.parse_command!(line), todos.Todos, geometry.Point).
open (unqualified). 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 "feels-like-part-of-the-language" libraries are conventionally opened by user code - but they are not auto-opened. A file that wants bare Some / None must write open option; without that, 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), since open order cannot be a tiebreak — hanki fmt sorts the open section, so order would silently rebind the name on reformat. Co-opening the modules stays legal (e.g. open env + open http, which both export get!); reach a collided name by its fully-qualified module.Name, and every name only one of them exports stays usable bare. A name the file itself declares shadows all opens, colliding or not.
Why both forms exist. open is not only ergonomics: an effect op is reachable only bare (§6) - there is no module.op call form - so open is the only way to reach another module's ops. Everywhere else it is convenience over a spelling that already works: core is ambient-qualified (below), and a qualified constructor is as legal in a pattern as in an expression (option.Some(v) -> v is a fine match arm). Hence the convention - open a module for its ops, or when its bare names are already universal (Some, None, Ok, Err); leave the rest qualified, where the prefix tells a reader which module a name came from.
use X as n (module-rename alias). A use may bind the module under a different file-local qualifier: use http as h, then reach its members as h.get!, h.Response. The alias is local to the file (like the import) and shadows nothing elsewhere; the qualifier resolves exactly as the real module would (h.Response is http.Response, and import-gating still applies to the real http). Its purpose is clash resolution - when two imported modules share a basename (two dependencies each exporting a json), alias one to disambiguate - and shorthand for a long name. as is supported only on use; open X as n is a parse error (open already brings names in bare). as is contextual - a keyword only in this position, so it stays usable as an ordinary identifier everywhere else.
Core is ambient-qualified; the extra tier is import-gated. The qualified spelling of a core stdlib module resolves with no import - option.Some, string.len, list.map all work with neither use nor open (an open only adds the unqualified spelling on top). The extra tier (§14's capability/utility modules - io, fs, net, http, time, datetime, json, xml, terminal, random, env, process, path, cbor, gen, arbitrary, vcs, supervisor, sqlite) 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 use/opens that module. Without the import the name is out of scope — a value reference is H0301 unknown name, a qualified type annotation (net.Conn) is H0302 unknown type — so each file declares the capability vocabulary it pulls in. (Any qualified type that names nothing in scope is the same H0302, so a typo'd foo.Bar is an error rather than a silently-accepted opaque type. The effect-row capabilities — [io], [net], [fs], … — are built-in language atoms rather than module members, so declaring one needs no import.) io is import-gated like the rest (the "conventionally opened" note below is an open-vs-use style convention, not ambient seeding). The prelude (below) and the whole core tier stay ambient regardless.
A user module may not shadow a stdlib module name. Because use <name> for a stdlib module always binds the standard library, a local file whose basename collides with a stdlib module — list.hk, time.hk, option.hk, … — can never be imported: the use resolves to the stdlib, so the local file would be silently ignored. The workspace rejects the collision with an error instead of dropping the file; rename the local module.
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 — including member access, so module.concat(name) calls the concat method of the local module value, not the module stdlib module. This is ordinary lexical scoping (the innermost binding wins), the same shadowing the prelude rule below grants a file's own declarations; without it a value reference would silently misdispatch to the module if it happened to export a same-named member. The module is then unreachable by that name inside the binding's scope — rename the binding if you need both.
Prelude: the core trait names and container types. The exception to "nothing is auto-opened" has two parts, both lowest-priority - a file's own same-named declaration shadows them. (1) Trait names Eq, Ord, Add, Sub, Hash, and Display are in scope unqualified everywhere, no open required, because these traits' behaviour is already ambient - == and != dispatch through Eq, </>/<=/>= through Ord, +/- on non-numeric operands through Add/Sub, #{…} interpolation through Display, and value.eq(…) / value.to_string() on a concrete value all resolve with no import - so naming one in a generic bound (def sort<T: Ord>(…), impl Eq<MyType>, impl Add<Instant, Duration>) must resolve the same way rather than forcing an open. What enters scope is the six trait names plus Ordering and its variants Less / Equal / Greater (so the result of cmp is matchable without open ord). (2) Container types List, Option, Result, and Map resolve to their stdlib types (list.List, option.Option, result.Result, map.Map) unqualified everywhere, so 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 / Err still need their module open (write open option for bare Some). Everything else - bare stdlib functions, ops, other types - still needs an explicit open.
Universal qualification. Every top-level item kind - def, actor, struct, type (including each variant constructor), trait, impl, effect, meta const - lives at its module-qualified name (option.Option, option.Some, todos.Todos, display.Display) in the merged workspace. Within the defining file the bare name still works (the Rewriter splices bare → qualified at workspace merge); 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 option # bare Some(_), None 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))
Some(id) -> types.Done(id)
None -> throw types.Parse(line)
end
...
Names starting with _ are not exported.
Multi-segment paths. use std.io.fs parses but is rejected by the resolver - single-name imports only in v0.1.
Stdlib modules are auto-injected by the compiler - use io / open option work without an io.hk / option.hk sibling on disk; the embedded stdlib owns them.