hanki

compiler

stdlib/core/compiler.hk: compiler reflection. Experimental.

Run the in-process Hanki front end over a source string and read its outputs back as ordinary Hanki values: a shallow parsed item list, the checker's diagnostics, the program's capability surface, and its documentation items. This is the single seam the off-path developer tools (fmt / doc / lint / effects and the rest) reflect through in place of being written in Rust.

The records below are destined for the 1.0 contract and remain unstable until the tool ports validate them. Treat them as provisional. api_version() returns the current schema revision so a consumer can detect a change.

Shallow by design: parse exposes item headers (kind, name, doc, span), not the expression and pattern trees: the deep syntactic AST is the pure-Hanki parser's job and no part of this seam. The typed views (diagnostics, effect_surface, doc_items) reuse the compiler's own analysis and are this seam's lasting surface. These run on both tiers: an AOT-compiled binary that calls one links the embedded front end, as a load!-using build does, and a reflected value is byte-identical across tiers.

Span

struct Span
  file_id: int
  start: int
  stop: int
end

A half-open [start, stop) byte range (the str.slice convention) into the source file file_id identifies. The reflected source is file_id 1, and its offsets slice the src you passed. diagnostics runs a full check, and a diagnostic may instead point at a stdlib definition site, a different file_id whose offsets index that file and never your src.

Item

struct Item
  kind: string
  name: string
  doc: Option<string>
  exported: bool
  action: bool
  property: bool
  span: Span
end

One top-level item, as a header alone with no body. kind is one of "import", "fn", "act", "struct", "type", "trait", "impl", "actor", "effect", "provide", "meta", "test". name is the declared name: the dotted import path for "import", the trait name for "impl", the label for "test". exported is false for a leading-underscore name; action is true for a !-suffixed name.

property is true for a @property test, which is otherwise indistinguishable from a plain one, both having kind == "test". It is a field and no separate kind: kind is what every consumer matches on, and moving property tests out of "test" would change, unannounced, what an existing tool counts, where a new field changes nothing until it is read.

Ast

struct Ast
  items: List<Item>
  doc: Option<string>
  span: Span
end

A shallow parsed module: its item headers, the leading module doc comment, and the whole-file span.

Import

struct Import
  path: List<string>
  is_open: bool
  alias: Option<string>
  span: Span
end

A use/open directive. path is the dotted module path as bare segments (use net.http → ["net", "http"]); is_open is true for open M (drops M's names into bare scope) versus use M (reached as M.name); alias is the use M as N qualifier, or None. The one header detail parse's Item collapses: a tool reconstructing name resolution needs the open and alias bits. open itself is a keyword, and the flag is therefore is_open.

StdTier

type StdTier
  Core
  Extra
end

Which tier an embedded stdlib module belongs to (HANKI.md §17): Core is ambient, and Extra must be imported before use. A sum and no is_core flag because the registry adds a community tier later, and a bool cannot grow a third case.

StdModule

struct StdModule
  name: string
  source: string
  tier: StdTier
end

One embedded standard-library module: its bare name (e.g. "option"), full source, and tier. Returned by stdlib_modules. The tier is derived from the directory the module's source sits in, and it therefore remains a single fact the compiler owns; a tool grouping an API index by tier reads it here and not hardcoding the core list, which would go stale the first time a module is promoted.

ProjectModule

struct ProjectModule
  name: string
  path: string
  reachable: bool
  source: string
end

One .hk module of a project. name is the bare name a use spells for it, which is its file stem, flat across the project, whatever directory the file sits in (a dotted use names a dependency's module, never a project path). Two files of one stem are a collision the compiler refuses; path still tells them apart, and a tool can name both. reachable is whether some root reaches this module through a chain of imports: a field and no filter, since hanki cddl emits the reachable ones and reports the rest while a doc index may reasonably list them all. source is carried rather than left to the caller so the module set and the text it describes are one snapshot; a consumer re-reading each path could otherwise see a file that changed since.

UnresolvedImport

struct UnresolvedImport
  name: string
  importer: string
end

A use that bound neither the standard library nor a module of the project, with the path of the file that wrote it. The normal cause is a declared dependency: resolving one means fetching it and writing a lockfile, which is and no part of a reflection call's remit. It is therefore reported and not resolved, and a tool that would otherwise emit references to types it never saw can say so in place of going quiet.

Project

struct Project
  entries: List<string>
  modules: List<ProjectModule>
  unresolved: List<UnresolvedImport>
end

A project's resolved module set: the roots reachability is rooted at, every .hk module ordered by path, and the imports that bound nothing.

entries is every program the manifest declares, then its exports, de-duplicated, the same set every CLI command answers about, and a file root is its own single entry. One field and no primary-plus-rest pair, and a tool therefore cannot read half a package by accident.

ParseError

struct ParseError
  message: string
  code: string
  span: Span
end

A parse failure: the rendered message, the stable diagnostic code (e.g. "H0101"), and the offending span.

Diagnostic

struct Diagnostic
  message: string
  code: string
  span: Span
end

One checker (or parse) diagnostic: the rendered message, its stable code, and the span it points at. Mirrors what hanki check reports.

EffectSite

struct EffectSite
  capability: string
  symbol: string
  span: Span
end

One site that declares a capability: the capability atom (e.g. "io", "fs_read", or a user effect name), the declaring symbol (a bare action name, Actor.handler, Receiver.method!, Trait.method!, or provide Effect), and the atom's span in the effect row.

EffectSurface

struct EffectSurface
  effects: List<string>
  sites: List<EffectSite>
end

A program's capability surface (HANKI.md §6): the sorted set of capabilities it can perform, and every site declaring them. Backs hanki effects.

DocItem

struct DocItem
  name: string
  qualified: string
  kind: string
  anchor: string
  signature: string
  effects: List<string>
  doc: Option<string>
  has_doctest: bool
  heading: string
  level: int
  line: int
end

One documentable symbol: its bare and qualified names, its kind, the stable cross-reference anchor (matching the hanki doc Markdown {#anchor} and the --format=json anchor), the rendered signature, the rendered effect row, the doc prose where present, and whether the doc has a runnable example. The heading (the rendered section heading: the bare name, or a head like impl Display<Point>) and level (2 for a top-level item, 3 for a member) drive the hanki doc Markdown sections. line is the 1-based source line of the declaration (its name token), for diagnostics like hanki doc --check. Backs hanki doc.

effects is the rendered row and no verbatim one of the kind Sig.effects has: it is sorted, and fs_read + fs_write together collapse back into the single fs alias they were written as. A consumer comparing the two is comparing two encodings of one row, and never two rows.

DiagCodeInfo

struct DiagCodeInfo
  code: string
  title: string
  explanation: string
end

One stable diagnostic code: the H#### string, its one-line title, and the canonical explanation with its fix recipe, the same text hanki explain prints. The whole registry is reflected by diag_codes, and an agent can therefore turn a Diagnostic.code into its explanation without shelling out.

Type

type Type
  Name(string)
  App(string, List<Type>)
  Unit
  Fn(List<Type>, Type, List<Effect>)
  Infer
end

A reflected type expression: the syntactic type tree, mirroring the parser's output verbatim. Name(string) is any bare identifier: a primitive (i32), a nominal type (Point), or a generic parameter (T) alike (the parser cannot tell them apart, and a consumer therefore recognises a generic parameter by matching its name against the enclosing declaration's generics). App(name, args) is a type application (List<T>, Map<K, V>, a user generic); Fn(params, ret, effects) a function type ((params) -> ret [effects], its effects a reflected effect row); Unit the () type; Infer an elided / inferred position.

Effect

type Effect
  Simple(string)
  Throws(Type)
end

A reflected effect-row member, mirroring the parser verbatim. Simple(name) is a bare effect tag, io, fs, state, Crash or a user effect, and, when name is a single lowercase letter (e, r), an effect variable (the consumer recognises it by that rule, as it does an implicit single-uppercase type parameter). Throws(Type) is a throws E clause carrying its error type.

Generic

struct Generic
  name: string
  bounds: List<Type>
  default: Option<Type>
end

A generic parameter: its name, the written bound types (T: Display yields bounds [Display]), and its trailing default (trait Add<Rhs = Self> yields Some(Name("Self"))). Only a trait declaration may carry a default; every other position reflects None.

Param

struct Param
  name: string
  ty: Type
  span: Span
end

A named parameter of a signature.

Sig

struct Sig
  params: List<Param>
  return_type: Type
  effects: List<Effect>
end

A signature: its parameters, return type, and written effect row as a reflected List<Effect> in source order, verbatim like the Type tree, and not the manifest's sorted/collapsed string encoding). An empty row is a pure def.

Field

struct Field
  name: string
  ty: Type
  span: Span
end

A struct field.

Variant

struct Variant
  name: string
  tag: int
  payload: List<Type>
  span: Span
end

A sum variant: its name, its 0-based declaration tag (the discriminant a codec writes), and its positional payload types (empty for a unit-like variant).

Method

struct Method
  name: string
  action: bool
  prop: bool
  encapsulated: bool
  intrinsic: bool
  generics: List<Generic>
  sig: Sig
  span: Span
end

A member signature inside a trait, impl, actor, or effect. action mirrors the ! action suffix on the member's name, a trait or impl def!: purely syntactic, like Item.action. A def, an effect op, or an on handler, none of which carry !, reflects as false; read sig.effects for what a member performs. prop is true for a trait or impl prop member (a bare-dot pure read, exclusive of the call form); always false for actor handlers and effect ops, which cannot be props. generics are the member's OWN type parameters (a trait or impl def map<U>) and never the container's; an actor handler and an effect op have none.

encapsulated and intrinsic mean what they do on Decl, and only an impl member can carry either: a trait signature, an actor handler and an effect op are declarations with no body to annotate, and all three therefore reflect false. Reading them here is not optional for a whole-package view: the standard library declares 25 @intrinsic members inside impl blocks, and a tool that only checked top-level declarations would call it pure Hanki.

StructBody

struct StructBody
  fields: List<Field>
  derives: List<string>
  is_opaque: bool
  where_source: Option<string>
end

A struct declaration's body. where_source is the joined source text of an opaque type's where block, absent when there is none.

SumBody

struct SumBody
  variants: List<Variant>
  derives: List<string>
end

A sum (type) declaration's body.

AssocBinding

struct AssocBinding
  name: string
  ty: Type
end

An impl's associated-type binding: type Output = Instant yields name "Output" bound to type Instant.

TraitBody

struct TraitBody
  supertrait: Option<string>
  methods: List<Method>
  type_members: List<string>
end

A trait declaration's body: its optional supertrait, its methods, and the names of its associated-type declarations (type Output yields "Output").

ImplBody

struct ImplBody
  trait_name: string
  trait_args: List<Type>
  members: List<Method>
  type_bindings: List<AssocBinding>
end

An impl block's body: the trait name, the types it is applied to (impl Display(Point) yields trait_args [Point]), its members, and its associated-type bindings (type Output = Instant).

ActorBody

struct ActorBody
  state_fields: List<Field>
  handlers: List<Method>
  has_migrate: bool
end

An actor declaration's body: its state fields, its message handlers, and whether it declares a migrate(old: T) hot-reload hook. The implicit [state] effect on every handler is not spelled out here.

EffectBody

struct EffectBody
  ops: List<Method>
end

An effect declaration's body: the operation signatures it declares.

ConstBody

struct ConstBody
  ty: Type
  value_source: string
  value_rendered: string
end

A meta constant's body: its declared type and its value expression in two forms. value_source is the verbatim source slice, and it shows what was written, reformatting included. value_rendered is the same expression printed back from the parse, and two spellings that differ only in layout render identically, which is what a comparison should use, a reformat is not a value change.

DeclBody

type DeclBody
  Fn(Sig)
  Struct(StructBody)
  Sum(SumBody)
  Trait(TraitBody)
  Impl(ImplBody)
  Actor(ActorBody)
  Effect(EffectBody)
  Const(ConstBody)
end

What a declaration is. Fn gives the signature of a top-level def or def! (the ! shows on the enclosing Decl.action); the rest carry their kind-specific shape.

Decl

struct Decl
  name: string
  exported: bool
  action: bool
  is_meta: bool
  encapsulated: bool
  intrinsic: bool
  generics: List<Generic>
  doc: Option<string>
  body: DeclBody
  span: Span
end

One top-level declaration, structured: the deep counterpart to the shallow Item. exported is false for a leading-underscore name; action is true for a !-suffixed name; is_meta is true for a top-level meta def, which shows in the rendered signature; generics is empty for a kind that takes none. The name of an impl is its trait name. Import, provide, and test items carry no type or signature and are omitted (use parse for the full header list).

encapsulated and intrinsic are the two attributes a caller cannot infer from the signature. An @encapsulated def presents an empty effect row like any pure one, and anything accounting for purity has to count it on its own line in place of folding it into either side; an @intrinsic body is a placeholder dispatching into the runtime, which is what makes it the hole in a "pure Hanki" claim. Only a def can be @encapsulated; both a def and a def! can be @intrinsic.

api_version

def api_version() -> int

The reflection schema revision. Bumped where a record above changes form, and a consumer can refuse a mismatch.

compiler.api_version() => 17

toolchain_version

def toolchain_version() -> string

The toolchain's own version: what hanki version prints, and what a tool emitting a committed artifact should stamp it with. A generated file whose provenance is unrecorded is one nobody dares regenerate.

Distinct from api_version, the reflection schema revision: that says whether a consumer can trust the reflected model, this says which hanki produced the file. A stamp wants both, labelled as what each is.

A plain constant and no seam, as api_version is. The value is fixed for a given toolchain, and reading it at run time would gain nothing and cost an intrinsic on both tiers. It cannot drift from the real version: toolchain_version_matches_the_crate in the compiler's own tests compares it against CARGO_PKG_VERSION and fails the build if they part.

compiler.toolchain_version().empty? => false

parse

def parse(src: string) -> Result<Ast, ParseError>

Parse src into a shallow Ast of item headers, or a ParseError. No type information, enough for syntactic tooling: outline, doc, lint.

The items are the raw ones the source wrote: no desugar has run, and a doctest fence inside a doc comment is not a synthesized test item and a @property test still has its parameters. Anything counting tests depends on that: the desugared view would report a test per doctest as well. @no-doctest: reflects over a source-string argument; a concise EXPR => VALUE example would need an embedded multi-line program; the round-trip is covered by the compiler_reflect integration tests

type_decls

def type_decls(src: string) -> Result<List<Decl>, ParseError>

Reflect the structured top-level declarations of src, types with their fields and variants, and the signatures of functions, trait / actor / effect members, and meta constants, as data, in source order. The deep counterpart to parse's shallow item headers, and like it purely syntactic (no type inference). Import, provide, and test items are omitted. A parse failure yields a ParseError. @no-doctest: reflects over a source-string argument; a concise EXPR => VALUE example would need an embedded multi-line program; the round-trip is covered by the compiler_reflect integration tests

diagnostics

def diagnostics(src: string, module_name: string) -> List<Diagnostic>

Parse and fully check src, returning every diagnostic hanki check would report (a parse failure surfaces as a single diagnostic). Empty when clean.

module_name names the stdlib module src is, and the shipped copy of it stands aside for the check; without that the file is ambiguous against its own duplicate. Pass "" for ordinary source; a name matching no stdlib module shadows nothing. @no-doctest: reflects over a source-string argument; a concise EXPR => VALUE example would need an embedded multi-line program; the round-trip is covered by the compiler_reflect integration tests

effect_surface

def effect_surface(src: string) -> EffectSurface

The capability surface of src (HANKI.md §6). An unparseable src yields an empty surface; call diagnostics to see why. @no-doctest: reflects over a source-string argument; a concise EXPR => VALUE example would need an embedded multi-line program; the round-trip is covered by the compiler_reflect integration tests

doc_items

def doc_items(src: string) -> List<DocItem>

The documentable items of src, in source order. An unparseable src yields an empty list. @no-doctest: reflects over a source-string argument; a concise EXPR => VALUE example would need an embedded multi-line program; the round-trip is covered by the compiler_reflect integration tests

diag_codes

def diag_codes() -> List<DiagCodeInfo>

Every stable diagnostic code, with its title and explanation, in registry order, the hanki explain data as values. It takes no source, being a constant view of the compiler's H#### registry. @no-doctest: returns the whole diagnostic registry (hundreds of multi-line entries); covered by the compiler_reflect integration tests

imports

def imports(src: string) -> Result<List<Import>, ParseError>

The use and open directives of src, in source order: the open and alias detail parse's Item collapses to a dotted path. A parse failure yields a ParseError. @no-doctest: reflects over a source-string argument; covered by the compiler_reflect integration tests

project_modules!

def project_modules!(root: string) -> Result<Project, string> [fs_read]

The module set of the project root names, resolved by the compiler's own import resolution and no walk the caller hand-rolls.

A directory root is a project: its manifest names the entry. A file root is its own entry, and its project is the tree of the nearest manifest enclosing it, or its own directory alone where none does, which is what lets a tool handed main.hk report on the modules main.hk sits among in place of seeing only the one file it was given.

The only seam here that reads the filesystem, hence the ! and the [fs_read] row; every other one takes a source string. It exists because a delegated tool is handed a path and no more, and without it there is no way to get from that path to the program. Resolving a use by hand instead would mean a second copy of the compiler's name resolution, free to drift from the real one without anyone noticing.

An Err reports a rendered message: root does not exist, its manifest is missing or will not evaluate, or the manifest's entry escapes the project directory. @no-doctest: reads a project from the filesystem; a concise EXPR => VALUE example would need a fixture tree on disk; covered by the compiler_reflect integration tests

project_diagnostics!

def project_diagnostics!(path: string, src: string) -> Result<List<Diagnostic>, string> [fs_read]

Every diagnostic hanki check would report for the module at path, checked in its package and never as a root of its own, with src standing in for the file's contents on disk.

The overlay is what diagnostics cannot express, and what a tool comparing two revisions of one file needs: the older side is a source string that never existed on disk, and checking it alone loses both halves of its context. Its siblings go missing - use numfmt reports no such module, then reports itself unused, the name it bound never having resolved; and the manifest's exports classification goes with them, and a module that publishes an effect interface reports H0610 for the interface it publishes.

The second seam here that reads the filesystem, for the siblings, hence the ! and the [fs_read] row. An Err reports a rendered message: path is not a file, or its project cannot be located. @no-doctest: checks a file in its package on disk; a concise EXPR => VALUE example would need a fixture tree; covered by the compiler_reflect integration tests

stdlib_modules

def stdlib_modules() -> List<StdModule>

The embedded standard library as (name, source) modules, in load order. Takes no source: a constant view of the compiler's bundled stdlib. Lets a tool reflect over the stdlib the way the compiler qualifies a user file against it (e.g. rebuilding the bare-name map an open introduces). @no-doctest: returns the whole embedded stdlib; covered by the compiler_reflect integration tests

bundled_card

def bundled_card() -> string

The compact agent bootstrap (HANKI-CARD.md) embedded in the compiler binary. Takes no source: a constant view. hanki new writes it into a scaffolded project, and a project's own agent can read the canonical card at runtime. @no-doctest: returns the whole embedded card (a multi-KB document); covered by the compiler_reflect integration tests

bundled_reference

def bundled_reference() -> string

The complete language reference (HANKI.md) embedded in the compiler binary: the full counterpart to bundled_card. Takes no source: a constant view. @no-doctest: returns the whole embedded reference (a multi-KB document); covered by the compiler_reflect integration tests

BundledSkill

struct BundledSkill
  name: string
  summary: string
  body: string
end

One embedded agent-agnostic skill: its slug, one-line summary, and the neutral markdown body hanki new writes to skills/<name>.md.

bundled_skills

def bundled_skills() -> List<BundledSkill>

The agent-agnostic skills embedded in the compiler binary, the source of truth hanki new writes into a scaffolded project's skills/ (and indexes from AGENTS.md). Takes no source: a constant view. Lets the Hanki hanki new port ship the same set as the Rust command without a second copy to drift. @no-doctest: returns the whole embedded skill set (multi-KB documents); covered by the compiler_reflect integration tests