Honey
Bro and Honey implement the same language; Bro is the prototype compiler used to bootstrap the self-hosted Honey compiler. the features below describe Honey's currently implemented subset. the expected development workspace places Bro at ../brolang; consult that project for the language grammar and documentation.
style: see the language guidelines.
build Honey from this directory:
bro build
the bootstrap must support contextual &[] literals for mutable slices; collection initialization and reset use these literals instead of a dedicated empty-slice helper. if the installed bro predates that feature, use ../brolang/build/brolang build after rebuilding Bro. zero-sized elements with nonzero logical lengths still use the memory module's specialized storage handling.
run a source file with the interpreter:
./build/honey run example.hon
run, emit-llvm and build also accept a directory. directory inputs compile its .hon and .bro files in bytewise filename order, excluding .test.hon/.test.bro and non-file entries; a file input still compiles only that file, not its siblings. declarations share a scope within each package, while dependencies have separate scopes and cannot supply the root package's main.
bare import "../library" and aliased library :: import "@library" declarations discover dependencies. relative paths start at the importing package directory; @ starts at the nearest ancestor containing build.hon or build.bro, falling back to the entry package directory. package directories are canonicalized through symlinks and loaded once. cycles, missing/empty dependencies and invalid import paths produce source diagnostics. absolute paths and empty, escaped or control-containing import strings are rejected. production loading ignores test-only imports. namespace aliases are not bound yet, and qualified calls such as library.add(...) remain unsupported.
add proc(left, right int) int {
return left + right
}
main proc() int {
answer :: add(19, 23)
return answer
}
this prints 42. both interpreted and Honey-compiled main must be a top-level function with no parameters; nonvoid results are printed, not used as process exit statuses.
compile the same supported language subset to a standalone Apple Silicon macOS executable:
./build/honey build programs/factorial.hon -o build/factorial -O2
./build/factorial
this prints 120. build requires -o <executable> and accepts -O0 (the default) or -O2 as its final option. Honey emits textual LLVM IR and invokes clang from PATH without a shell, targeting arm64-apple-macosx11.0.0. Apple Clang 21 is the verified toolchain; LLVM libraries, LLVM bindings and a C emission stage are not required. the temporary IR file is private and removed after compilation, including on failure.
inspect or compile the LLVM text separately:
./build/honey emit-llvm programs/factorial.hon > build/factorial.ll
clang -target arm64-apple-macosx11.0.0 -O2 build/factorial.ll -o build/factorial
emit-llvm writes LLVM text to stdout and source diagnostics to stderr. both emission commands return 1 for recoverable source diagnostics while still producing their output when emission and linking succeed. the resulting executable preserves demand-driven traps, reports all source diagnostics, and returns 1 even if unaffected code completes. runtime-generated diagnostics retain source locations; the original source file and compiler are not needed to run the executable. invocation or Clang failures return 2.
the backend (@source/llvm) consumes the existing typed IR, emits native functions and control flow, and uses entry-block stack slots for locals and instruction results, including non-SSA result IDs. LLVM can promote eligible slots during optimization. arrays use typed native aggregates with by-value copies; loop storage is allocated once per invocation. signed and unsigned arithmetic use checked overflow intrinsics, array accesses retain bounds checks, local initialization flags reset on scope re-entry, globals initialize lazily with cycle checks, and floating operations preserve IEEE behavior without fast-math flags. native calls use the native stack rather than the interpreter's heap-backed frames.
run the bootstrap suite with bro test. after bro build, run python3 tests/native.py for executable-level interpreter comparisons at both -O0 and -O2, including traps, source diagnostics, array ownership, calling conventions, and CLI error handling.
the native bootstrap accepts either a no-argument main or main proc(init process.Init). it supplies init.io and init.args from native startup argc/argv. startup scans each argument once into a heap-allocated array of borrowed zero-terminated slices (process.Args.values: [][;0]u8), without copying strings. the descriptor array and borrowed strings remain valid until main returns; startup then frees the descriptors, including on a fallible return. allocation failure exits with status 2. init.args.values.len includes the executable name, and init.args.values[index] directly accesses an argument after a length guard. callers can supply their own process.Args { values = slices[..] } for testing.
process.run(allocator, arguments) starts a child synchronously using Darwin posix_spawnp, searches PATH, and inherits the environment and standard streams. argument strings are borrowed zero-terminated slices; no shell parsing occurs. it releases temporary argument descriptors on every return, retries interrupted waits, and returns the exit code or 128 + signal for signal termination.
source loading uses the supplied io.Io for opening, reading, and closing files. io.open accepts a borrowed zero-terminated slice ([;0]u8), including init.args.values[index] without copying its storage. the bootstrap supports checked sentinel-slice construction with pointer[..length;0]. io.read_all(allocator, reader, maximum) reads to EOF into a caller-owned ArrayList(u8); release it with arraylist.deinit. it frees partial storage on error and consumes one extra byte to distinguish an exact-size input from a limit violation. the driver maps limit_exceeded to source_too_large at its u32 source-size limit.
a recursive example is in programs/factorial.hon; ./build/honey run programs/factorial.hon prints 120.
an iterative example is in programs/iterative_factorial.hon; ./build/honey run programs/iterative_factorial.hon also prints 120.
the parser supports arithmetic expressions with nested parenthesized groups, such as a + (b - c) * 2 / 3. groups control precedence without adding AST nodes; inner expression spans exclude the grouping parentheses. groups may span lines in any expression position, including returns, initializers, arguments and guards. parsing accepts integer division syntax, but type checking requires float operands for /.
function calls are postfix expressions: f(), f(a, b), and factory()(x). calls bind tighter than unary and binary arithmetic and can appear in initializers or as expression statements inside function bodies. argument lists allow newlines and a trailing comma; outside parentheses, newlines remain statement boundaries. type checking validates callable values, argument counts and argument types.
true and false are boolean literals; bool is available for parameters, storage and results. ==, !=, <, <=, > and >= compare compatible numeric operands and produce bool; == and != also accept boolean operands. comparisons preserve contextual numeric inference without making their operands boolean. arithmetic binds tighter than relational comparisons, which bind tighter than equality; comparisons associate left to right. boolean arithmetic/ordering and string/function/void comparisons are invalid; there is no implicit numeric-to-boolean conversion.
!, and and or require boolean operands and produce bool. prefix ! and - bind tighter than arithmetic; equality binds tighter than and, which binds tighter than or. binary operators associate left to right, while prefixes nest right to left. operands evaluate left to right: false and rhs and true or rhs skip the right operand, including calls, lazy global initialization and runtime traps. both operands are still parsed, resolved and type-checked; errors in a skipped operand remain source diagnostics without forcing a runtime trap. this applies in initializers, assignments, returns, call arguments and conditions. programs/boolean_conditions.hon demonstrates combined predicates; ./build/honey run programs/boolean_conditions.hon prints true.
if/else supports braced arms, single statements and else if chains. conditions must have type bool. a braceless guard requires parentheses unless it is a function call: if (n <= 1) return 1 or if ready() return. else binds to the nearest if; after a braced arm it may start on the next line. branches are represented as AST blocks even when braceless.
name = expression updates a local := binding. :: bindings, parameters, named functions, builtin types and globals are not assignment targets. the established binding type is preserved: assignment inputs do not widen or infer that type, though numeric literals can adopt its float context. function-valued locals accept replacement functions with the same parameter and result types.
fixed arrays use [N]T for readonly elements and [N]mut T for mutable elements; literals use [a, b], including empty, nested and multiline forms with trailing commas. values [4]mut i64 := [1, 2, 3, 4] permits values[2] = 10. writable elements require a mutable local binding and mut at every traversed array level; parameters and globals remain readonly. whole-array assignment only requires a mutable binding, but source and destination types must match recursively, including counts and element mutability. initializations, assignments, arguments and returns copy arrays by value, including nested rows.
array lengths currently support nonnegative integer literals, unary signs, +, -, * and package-level immutable integer constants; local constants and other compile-time expressions are not implemented. [_] infers a binding's array length from its initializer. element annotations require concrete supported value types: use i64 or f64 rather than the int or float constraints. an unannotated array literal has readonly elements.
indices require usize; integer literals and flexible inferred values receive this context, while fixed signed integers do not convert implicitly. out-of-bounds access traps before loading or storing, including for empty arrays. reads evaluate the base before the index; indexed assignment evaluates its right-hand side before the destination, each once. leading nested assignment targets follow Bro's declaration ambiguity: write (matrix[1])[0] = 9, not matrix[1][0] = 9. programs/array_sum.hon uses a usize range bound to index, mutate and sum an array; ./build/honey run programs/array_sum.hon prints 17.
while repeats a block or single statement while its bool condition holds, re-evaluating the condition on every iteration. guards follow the same braced/braceless convention as if. loop bodies introduce child scopes; their locals, including locals in nested conditional arms, begin uninitialized on each scope entry.
for 0..10 |i| { ... } iterates an ascending, upper-exclusive integer range; ..= includes the upper bound. a capture is required: |i| binds an immutable value with the bounds' integer family (int or usize) in the body scope, while |_| discards it. bounds resolve in the enclosing scope and are evaluated left-to-right once, so changing a bound variable in the body does not change the range. reversed ranges are empty; equal bounds produce zero iterations with .. and one with ..=. inclusive ranges terminate safely at the maximum integer.
range bounds that are compound or unary expressions require their own parentheses, such as (-2)..(limit + 1). a braceless body also requires parentheses around the entire iterable: for (0..10) |i| total = total + i. current execution supports direct integer ranges only; first-class range values, collection iteration, pointer captures and index captures remain unimplemented. programs/range_loop.hon skips 5 while summing 0..10; ./build/honey run programs/range_loop.hon prints 40.
break exits the nearest enclosing for or while; continue restarts a while condition or advances a for range through its guarded update. both are operand-free statements permitted within loop bodies and nested conditional arms; labels and values are unsupported. controls outside loops produce keyword-spanned diagnostics, including in untaken or unreachable code, and trap only if reached. code after a transfer is still resolved and type-checked but does not execute on that path. body locals are reset on re-entry after continue; loop transfers do not guarantee a function return. both controls lower to existing IR jumps, without new interpreter instructions or loop frames. programs/loop_control.hon skips an iteration and exits early; ./build/honey run programs/loop_control.hon prints 12.
name resolution (@source/sema) runs after parsing. it predeclares package names and each block's direct locals, allowing forward references and mutually recursive functions. parameters and top-level function-body locals share a scope; each conditional arm and loop body introduces a child scope whose names do not escape it. signatures resolve names in the enclosing package scope. shadowing is forbidden, including reuse of builtin type names (int, float, i64, f64, usize, bool, string, void); sibling scopes may independently reuse names.
the driver uses @source/compilation to own loaded source buffers, copied paths, one AST arena, the package graph and a shared identifier pool. add_source transfers its ArrayList(u8) allocation only on success, resetting the caller's list; infrastructure failure leaves the bytes caller-owned and rolls back the source registration, partial AST and source diagnostics. interned names may remain in the shared pool. deinit releases all retained frontend storage. metadata borrows are invalidated by registration growth, but source/package/node IDs remain stable within their compilation.
AST NodeId values are compilation-wide, so declarations in different files cannot alias. spans carry their originating diagnostics.SourceId; ast.source_files exposes borrowed paths and text. compilation.compile resolves each package independently, checks the complete arena and lowers one independently owned IR module, selecting an entry only from the requested root package. interpreter diagnostics and generated native traps retain the originating filename and local line/byte-column. borrowed ast.view trees must be refreshed after builder mutation and must not be passed to ast.deinit.
identifier interning is compilation-owned: initialize a strpool.StringPool, pass it to lexer.scan and sema.resolve before the diagnostic store, and release it after lowering. scanning interns identifier spellings once per pool; parsing preserves their IDs in compact AST storage, exposed through ast.name_id, while retaining source spans for diagnostics. name resolution keys scopes by those IDs and interns builtin names into the same pool, so identifier hashing and equality do not scan spelling bytes. IDs are meaningful only within their originating pool; independent compilations may assign the same numeric ID to different names. interning failures propagate as infrastructure errors rather than producing an invalid identifier ID. IR retains no pool dependency.
resolver scopes use @std/hashmap.AutoHashMap(K, V), which shares init, put, get and deinit with custom HashMap and StringHashMap. automatic keys support integers, booleans, enums, distinct types, fixed arrays and records recursively; record padding is ignored. slices, floating-point values, pointers, optionals and unions require explicit hashing and equality through HashMap (StringHashMap supplies content-based byte-string operations). keys and values are stored by value; referenced resources remain caller-owned. the builtin declaration count is inferred, and builtin ID storage derives its fixed stack-array length from that declaration array rather than repeating a count or initializer.
sema.resolve returns an owned binding table indexed by AST NodeId, without modifying the AST. sema.binding identifies a declaration, builtin type, or invalid binding carrying its diagnostic ID; nodes with no binding return .none. release the result with sema.deinit. unknown names and duplicate declarations add source-spanned diagnostics; invalid syntax retains its existing diagnostic identity. same-scope duplicates retain the first declaration for recovery, while forbidden inner declarations invalidate that name's uses rather than silently binding them to an outer declaration.
type checking (@source/sema/check) runs after name resolution. check.check borrows the AST and binding table and returns owned per-node types and interned array descriptors; query with check.type_of and check.array_type, and release with check.deinit. declarations infer their initializer types, including forward references and function aliases; function types identify the declaration supplying the callable signature. annotations support builtin types and fixed arrays; type names are not ordinary values. void is valid as a function result, not as a parameter or stored value.
function fallthrough obligations are separate from body types. check.fallthrough_diagnostic(&result, function) accepts a function declaration's NodeId and returns its original missing-return diagnostic ID, or null when no obligation exists. nested body errors retain their original identities. lowering consumes this fact without interpreting diagnostic codes and places its trap only on a surviving fallthrough path; check.deinit releases the obligation storage.
the checker keeps union-find parent links and group sizes in a compact array separate from per-node inference metadata. root walks and path compression access only that link storage; both temporary arrays are released before check.check returns.
numeric typing follows contextual rules within Honey's current int and float families. integer literals can adopt a supported numeric family only when their value is exactly representable; float literals remain float and opt into ordinary IEEE rounding. numeric demands propagate through arithmetic and inferred bindings before undemanded integers default to int. conflicting demands are errors; fixed int/i64, usize, and float/f64 values do not implicitly convert between families. +, -, * and unary - require compatible numeric operands; / requires and supplies float context. 3 / 2, 3.0 / 2, and ratio float :: 3 / 2 are valid. value f64 :: 9007199254740993 is diagnosed, including when float context arrives through aliases, division, or array elements; 9007199254740993.0 explicitly permits rounding. exactly representable integers beyond the signed/unsigned integer ranges remain valid in float context. explicit numeric casts are not yet implemented.
the conversion policy is value-preserving by default: any future implicit widening must preserve every value of the source type, not merely the value observed at one use. narrowing, potentially inexact integer-to-float conversion, float-to-integer conversion, and signedness changes that cannot preserve the full source range require an explicit operation. integer arithmetic remains checked rather than wrapping; floating arithmetic retains IEEE rounding.
@std/strconv.parse_float(allocator, text) parses a complete borrowed []u8 decimal into f64. it accepts an optional sign, decimal point, and exponent; whitespace, embedded NUL bytes, trailing input, hexadecimal, infinities, and NaNs return invalid_number. finite subnormals and signed zero are preserved; overflow and nonzero values rounded to zero return out_of_range. allocation failures propagate as out_of_memory, and temporary storage is always released. the Darwin backend uses libc's C-locale conversion without changing the process locale; rounding follows the caller's floating-point rounding mode. numeric lowering uses this interface, keeping exact integer-to-float validation compiler-specific.
type errors carry source spans and diagnostic identities; invalid operands propagate their existing identity without duplicate errors, while independent expressions, arguments and nested bodies are still checked. inference cycles without a type anchor are diagnosed. function signatures are checked independently of their bodies. bare return and same-line return expression are supported inside function bodies and must match the declared result type. nonvoid return-path checking is structural: every path must return or terminate in a diagnostic trap, and both conditional successors are considered without constant folding. a missing else or a loop permits fallthrough, even for a literal true condition; a later return can close that path. statements after a return and untaken branches or loops are still type-checked but are not executed.
lower.lower turns the checked AST and binding table into an owned @source/ir module. functions, globals, local slots and instruction-result temporaries have explicit IDs; typed instructions execute in source evaluation order and basic blocks end in jump, conditional-branch, return or diagnostic-trap terminators. conditional arms join only when execution can continue; loops use condition/body/exit blocks and backedges. short-circuit expressions use branches and synthetic boolean local slots to merge results; call arguments are packed in the block reached after evaluating them. local slots and temporaries are function-wide, without SSA or phi nodes. literals are parsed and strings decoded during lowering; the module retains no AST, binding-table or source-buffer references. release it with ir.deinit; keep and reuse the diagnostic store used during lowering so diagnostic IDs remain valid. the IR is target-independent and is intended to feed later native backends as well as the current interpreter.
array IR retains recursive element types, counts and mutability. typed aggregate construction, element addresses and load/store instructions use flattened storage reserved per invocation; nested copies and indexed accesses reuse that storage without per-iteration allocation. addresses are internal IR values, not source-language pointers.
IR construction owns derived layout metadata. ir.add_array checks shape capacity and computes flattened leaf counts; ir.add_parameter and ir.add_local create slots with their disjoint array backing. parameters precede other locals. ir.emit assigns fresh result IDs by default and maintains temporary counts, array-result segments and address presence. hand-authored non-SSA IR may supply explicit result IDs; repeated array results retain their shape and reuse the same segment. producers do not register backing separately or write derived counts, offsets or flags. construction failures leave logical contents unchanged, and no finalization pass or extra runtime allocation is required.
interpreter.run consumes only IR and returns an Outcome containing a value or diagnostic trap. returned arrays own their escaped cell storage; release every outcome with interpreter.deinit before releasing its module. string values carry a module-local ir.StringId, not a slice; resolve bytes with ir.string_bytes(&module, id) before releasing the module. decoded bytes and their full backing allocations remain module-owned, without copying strings during calls or returns. array descriptors also borrow the module. heap-backed invocation frames track their current block and resume it after calls or global initialization; loop iterations reuse the invocation's storage without allocating new frames. parameters are initialized on entry and local declarations execute in statement order. premature local reads and assignments trap; assignment evaluates its right-hand side before checking the destination's initialization. globals initialize lazily on their first read, cache their result for that run, and trap on initialization cycles. int/i64 use signed 64-bit storage, usize uses unsigned 64-bit storage, both with checked arithmetic; float/f64 use 64-bit IEEE arithmetic and comparisons, including NaN behavior. usize supports +, -, * and comparisons, but not unary negation or division. numeric literal overflow and float underflow to zero are diagnosed; representable subnormals are accepted. strings support \\, \", \n, \r, \t, \0 and \xNN escapes.
recoverable source errors do not stop execution of unaffected code. unused invalid functions, globals and nested bodies retain diagnostic traps; reaching invalid code reports its original diagnostic identity. missing-return traps affect only paths that fall through, not paths that already return or trap. diagnostics include the filename, line and byte column. exit status is 0 for clean execution, 1 for source diagnostics or a runtime trap, and 2 for usage or infrastructure failure. compound assignment remains unimplemented.