hanki

config

stdlib/core/config.hk: configuration files, at build time and at run time.

Two halves, and the difference between them is the whole module. load evaluates a config the project itself ships, at build time. parse reads a config that arrived from somewhere else, at run time, and never evaluates anything: a .config.hk is a Hanki file, and evaluating one that came from a peer, a synced directory or a user's home would be arbitrary code execution.

load, at comptime

config.load(path) reads a .config.hk file at build time, type-checks and evaluates it (against the stdlib plus the host's entry module), and bakes the resulting value into a constant. It is pure and comptime-only: the load folds to a constant at compile time and has no runtime presence, and it therefore has no effect row. A failure, a missing file, a config that does not type-check, or a capability a zero-capability load cannot satisfy, is a compile error and no runtime throw.

The file's value is its trailing top-level expression, which constructs the host type T. The config file may open the host's entry module to reach that type. For a host whose entry module myapp declares struct AppConfig:

# app.config.hk open myapp AppConfig(port = 8080u16, host = "localhost")

the host pins T through the binding annotation, there being no call-site type argument, and the path resolves relative to the entry file's directory, confined to the enclosing project's root (an entry at src/main.hk reaches "../app.config.hk" beside hanki.config.hk; climbing past the root is refused):

CONF: AppConfig = config.load("app.config.hk")

parse, at run time

config.parse(src) reads the declarative subset of the same file format from a string, at a path the program discovers while running. It is a parser and no evaluator, and what it accepts is narrow:

config := (comment | setting)* setting := NAME '=' value value := STRING | INTEGER | 'true' | 'false' | '[' values ']' comment := '#' … end of line

NAME is an identifier, STRING a double-quoted literal with the language's own escapes (\n, \t, \r, \0, \\, \", \#), and INTEGER a base-10 whole number with optional - sign and _ separators. A list may span lines and takes values of any of these kinds, nested.

Everything else a .config.hk may hold is refused with a ConfigError naming the line: open, def, a struct construction (Dep(source = …)), a trailing expression, #{…} interpolation inside a string, a fractional number, a width-suffixed number (8080u16), and the typed binding form (port: u16 = 8080) that §21 also allows. Those carry meaning this value model cannot hold, and reading them back as untyped data would lose it without a word. Anything parse accepts, load accepts and agrees with.

parse reports every key it finds. Deciding which keys are known, and refusing the rest, as the project manifest does, belongs to the caller, which is the only side that knows them.

load

def load<T>(path: string) -> T

Load and evaluate the .config.hk file at path (relative to the entry file) and return its trailing value, typed T by the binding annotation. @no-doctest: loads a build-time config file; not expressible as a self-contained doctest

ConfigValue

type ConfigValue
  Str(string)
  Int(int)
  Bool(bool)
  Items(List<ConfigValue>)
end

The value of one setting: the four shapes the declarative subset admits.

impl ConfigValue

as_string

def as_string(self) -> Option<string>

The string payload, or None on any other kind.

Str("hi").as_string() => Some("hi")
Int(1).as_string()    => None

as_int

def as_int(self) -> Option<int>

The whole-number payload, or None on any other kind.

Int(7).as_int()    => Some(7)
Str("7").as_int()  => None

as_bool

def as_bool(self) -> Option<bool>

The boolean payload, or None on any other kind.

Bool(true).as_bool() => Some(true)
Int(1).as_bool()     => None

as_items

def as_items(self) -> Option<List<ConfigValue>>

The list payload, or None on any other kind.

Items([Str("a")]).as_items().map(|xs| xs.length) => Some(1)
Str("a").as_items().map(|xs| xs.length)          => None

impl Eq<ConfigValue>

eq?

def eq?(self, other: Self) -> bool

Equal when the kinds match and their payloads do.

Str("a").eq?(Str("a")) => true
(Int(1) == Str("1"))   => false

ConfigError

struct ConfigError
  line: int
  reason: string
end

Why a config did not parse: the 1-based line the problem was found on, and what was wrong with it.

impl Display<ConfigError>

to_string

def to_string(self) -> string

Renders as line <line>: <reason>.

ConfigError(line = 3, reason = "expected a value").to_string() => "line 3: expected a value"

impl Eq<ConfigError>

eq?

def eq?(self, other: Self) -> bool

Equal when both the line and the reason match.

ConfigError(line = 1, reason = "x").eq?(ConfigError(line = 1, reason = "x")) => true

parse

def parse(src: string) -> Result<Map<string, ConfigValue>, ConfigError>

Read the declarative subset of a .config.hk file out of src, as its settings by name. Nothing in src is evaluated, and an untrusted config is data and not code.

parse("n = 5\n").map(|m| m.get("n"))               => Ok(Some(Int(5)))
parse("xs = [\"a\"]\n").map(|m| m.get("xs"))       => Ok(Some(Items([Str("a")])))
parse("open pkg\n").map_err(|e| e.line)            => Err(1)

maxdepth

def _max_depth() -> int

Deepest list nesting the parser will descend into. Bounds the recursive descent so an untrusted config cannot overflow the call stack; far past any config anyone writes.

maxsettings

def _max_settings() -> int

Most settings one config may declare. They land in a Map whose hash is unkeyed FNV-1a and therefore attacker-predictable, and an untrusted config free to pile colliding names into one bucket would make building the map quadratic.

BytesReader

BytesReader, or bytes.BytesReader, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.

_settings!

def _settings!(input: bytes, r: BytesReader, acc: Map<string, ConfigValue>, budget: int) -> Result<Map<string, ConfigValue>, ConfigError>

expectequals!

def _expect_equals!(input: bytes, r: BytesReader, name: string) -> Result<(), ConfigError>

name: TYPE = value is a valid .config.hk binding that this subset has no way to honour: the annotation can name a type no ConfigValue has, and dropping it would reinterpret the setting unannounced, and it is therefore named as its own refusal in place of reported as a missing =.

skiptrivia!

def _skip_trivia!(r: BytesReader, newlines: bool) -> ()

Skip spaces, tabs, carriage returns and # comments. newlines also skips line breaks: between settings they separate nothing worth keeping, while inside one setting a line break ends it.

_trivia?

def _trivia?(c: Option<u8>, newlines: bool) -> bool

skipcomment!

def _skip_comment!(r: BytesReader) -> ()

_drop!

def _drop!(r: BytesReader) -> ()

Consume one byte, discarding it.

readname!

def _read_name!(input: bytes, r: BytesReader) -> Result<string, ConfigError>

namebyte?

def _name_byte?(c: u8, first: bool) -> bool

_alpha?

def _alpha?(c: u8) -> bool

_digit?

def _digit?(c: u8) -> bool

readvalue!

def _read_value!(input: bytes, r: BytesReader, depth: int) -> Result<ConfigValue, ConfigError>

readword!

def _read_word!(input: bytes, r: BytesReader) -> Result<ConfigValue, ConfigError>

readint!

def _read_int!(input: bytes, r: BytesReader) -> Result<ConfigValue, ConfigError>

BytesBuilder

BytesBuilder, or bytes.BytesBuilder, is a runtime-managed native resource handle (HANKI.md §4): live native state the per-actor memory manager owns, released when the last handle drops. It is single-owner - never copied, moved across a send - and has no fields of its own, so its methods are its whole surface. A handle is minted by an API that opens one; it is never constructed.

finishint!

def _finish_int!(input: bytes, r: BytesReader, at: int, out: BytesBuilder) -> Result<ConfigValue, ConfigError>

intof!

def _int_of!(input: bytes, at: int, out: BytesBuilder) -> Result<ConfigValue, ConfigError>

readitems!

def _read_items!(input: bytes, r: BytesReader, depth: int) -> Result<ConfigValue, ConfigError>

readstring!

def _read_string!(input: bytes, r: BytesReader) -> Result<ConfigValue, ConfigError>

stringof!

def _string_of!(input: bytes, at: int, out: BytesBuilder) -> Result<ConfigValue, ConfigError>

readescape!

def _read_escape!(input: bytes, r: BytesReader, out: BytesBuilder) -> Result<(), ConfigError>

_escaped

def _escaped(c: u8) -> Option<u8>

The escapes the lexer defines, and only those.

_fault!

def _fault!(input: bytes, r: BytesReader, reason: string) -> ConfigError

faultat

def _fault_at(input: bytes, at: int, reason: string) -> ConfigError

lineat

def _line_at(input: bytes, at: int) -> int

bumpline

def _bump_line(n: int, b: u8) -> int