Common Development Tasks
July 26, 2026 · View on GitHub
Step-by-step guides for common tasks in the Beamtalk codebase.
Adding a New AST Node
- Define type in
crates/beamtalk-core/src/ast.rs - Add parsing in
crates/beamtalk-core/src/source_analysis/ - Add type checking in
analyse.rsif needed - Add Core Erlang generation in
erlang.rs - Add snapshot tests in
test-package-compiler/cases/
Adding a CLI Command
- Add command enum variant in
crates/beamtalk-cli/src/main.rs - Implement handler function
- Add integration test
- Update
--helpdocumentation
Adding a New Standard Library Feature
Follow this example-driven workflow:
- Define the class in
stdlib/src/YourClass.bt- Include comprehensive documentation
- Use
@primitive intrinsicNamepragmas for methods backed by runtime dispatch (see ADR 0007) - Pure Beamtalk methods need no pragma — they compile directly
- Provide usage examples in comments
- Subdirectories under
stdlib/src/are allowed and purely editorial — see Stdlib source layout below
Stdlib source layout
stdlib/src/ may be grouped into subdirectories (collections/, actors/, …).
The build walks it recursively, so a nested class compiles, documents, installs
and loads exactly like a top-level one — a flat and a nested tree produce
byte-identical beamtalk_stdlib.app output.
Subdirectories never affect module names. stdlib/src/collections/Array.bt
compiles to bt@stdlib@array, not bt@stdlib@collections@array. This
deliberately diverges from user packages, where src/util/math.bt becomes
util@math.
The reason is that stdlib's class→module mapping is a closed-form function of the class name, with no path and no lookup table involved:
value_type_codegen::compiled_module_name(packages use a two-passclass_module_index; stdlib falls through tobt@stdlib@{snake})PrimitiveBindingTable::runtime_module_for_classbeamtalk_primitive:module_for_value/1— dispatches onis_integer(X), so no class name is even available to look up
Folding directories into the atom would couple that hot dispatch path to a cosmetic layout choice, and turn every future file move into an ABI change.
Two consequences:
- Class file names must be unique across all subdirectories, and unique
after case-folding —
to_module_namelowercases, soBEAMError.btandBeamerror.btboth yieldbt@stdlib@beamerror.build-stdlibfails with a duplicate-module error rather than silently clobbering a module inebin/. - Moving a class between subdirectories is free at build time, but does
invalidate any
stdlib/src/...paths written in docs and comments. Update those in the same change.
-
Implement runtime support (for primitive methods only)
- Add dispatch clause in the appropriate runtime module (e.g.,
runtime/apps/beamtalk_runtime/src/beamtalk_primitive.erl) - Runtime modules provide type checking, structured errors, and extension registry fallback
- Add intrinsic name mapping in the compiler's intrinsic registry (one line)
- For pure Beamtalk methods, skip this step — the compiler handles them directly
- Add dispatch clause in the appropriate runtime module (e.g.,
-
Create test fixtures in
tests/repl-protocol/fixtures/- Create simple, reusable classes demonstrating the feature
- Keep fixtures minimal and focused
-
Write REPL-protocol tests in
tests/repl-protocol/cases/- Test primitives first
- Use
@loaddirective to load fixtures for actor tests - Cover edge cases and error conditions
-
Create runnable example in
examples/- Show how users would actually use the feature
- Include step-by-step REPL session walkthrough
- Explain what's happening under the hood
- Document BEAM mapping and generated code
-
Run tests after EVERY change
just test-stdlib # Bootstrap expression tests (~14s) just test-bunit # BUnit TestCase tests just test-repl-protocol # REPL integration tests (~50s) -
Update
stdlib/src/README.md- Add to class hierarchy or core classes section
- Update BEAM mapping table
- Update implementation status table
Example from BT-176 (ProtoObject):
- ✅ Class:
stdlib/src/ProtoObject.bt— pragma declarations + pure Beamtalk methods - ✅ Runtime:
runtime/apps/beamtalk_runtime/src/beamtalk_primitive.erl— dispatch for ProtoObject methods - ✅ Tests:
stdlib/test/BUnit tests for the class - ✅ Docs: Updated
stdlib/src/README.mdwith class hierarchy
Validating Generated Dialyzer Specs
Beamtalk generates -spec attributes from type annotations (e.g. :: Integer, -> String). Since Core Erlang compilation does not preserve specs in BEAM debug_info, a separate validation pipeline verifies that generated specs are well-formed.
Quick check
just dialyzer-specs # Compile stdlib → extract specs → Dialyzer validation
How it works
beamtalk build --stdlib-modecompiles.btsources to.corefiles with'spec'attributesscripts/validate_specs.escriptextracts those spec terms, constructs Erlang abstract forms with the actual type expressions, compiles to BEAM withdebug_info, and runs Dialyzer- Dialyzer validates that all type expressions in the specs are well-formed (unknown types, malformed tuples, etc. are caught)
Integration tests
cargo test --test spec_validation -- --ignored # Round-trip tests + negative test
The integration tests in crates/beamtalk-cli/tests/spec_validation.rs verify:
.btwith type annotations → Core Erlang → Dialyzer validation passes- Correct type mappings (Integer→integer, String→binary, etc.)
- Invalid spec types are detected (negative test)
Adding new type mappings
When adding a new type to spec_codegen.rs:
- Add the mapping in
simple_type_to_spec() - Add a unit test in the
testsmodule - Run
just dialyzer-specsto verify the generated spec is valid - Run
cargo test --test spec_validation -- --ignoredfor the full round-trip
Debugging Compilation
- Use
BEAMTALK_DEBUG=1to print intermediate representations - Check generated
.corefiles inbuild/directory - Use
erlc +debug_infofor BEAM debugging - Inspect BEAM files with
:beam_lib.chunks/2
Example-Driven Development
When implementing new language features, ALWAYS follow this pattern:
- Create a runnable example in
examples/showing the feature in use - Create test fixtures in
tests/repl-protocol/fixtures/if testing classes/actors - Write REPL-protocol tests in
tests/repl-protocol/cases/that load and use the fixtures - Run REPL-protocol tests to verify end-to-end functionality:
just test-repl-protocol - Iterate until all tests pass
Why this matters:
- Examples provide immediate validation that your feature actually works
- REPL-protocol tests catch integration issues that unit tests miss
- Test fixtures can be reused across multiple test files
- Users get working examples to learn from
Using @load for Stateful Tests
The REPL-protocol test infrastructure supports loading class definitions with the @load directive:
// tests/repl-protocol/cases/my_test.btscript
// @load tests/repl-protocol/fixtures/my_class.bt
instance := MyClass spawn
instance someMethod
// => expected_result
How it works:
- Test parser extracts
// @load <path>directives - REPL compiles each file via the compiler daemon
- Classes are loaded into the REPL session
- Test expressions can spawn and use those classes
Best practices:
- Put reusable classes in
tests/repl-protocol/fixtures/ - Keep examples in
examples/for user learning - Use
@loadfor any test requiring actor/class definitions - Run REPL-protocol tests after every compiler change
- ALWAYS add
// =>assertions - Expressions without expected output are skipped!
REPL-Protocol Response Verification Rules
Rule 1: Every expression MUST have a // => assertion
// ✅ GOOD - Verifies response
42 + 3
// => 45
// ❌ BAD - Expression is skipped, not tested!
42 + 3
Rule 2: Test deterministic values, document non-deterministic ones
// ✅ GOOD - Primitives have deterministic output
42 class
// => Integer
'hello' == 'hello'
// => true
// ⚠️ NON-DETERMINISTIC - Can't match exact PID
// Instead, test what CAN be verified:
Counter spawn // Just verify no error
c class // Can't test - 'c' wasn't assigned with => assertion
Rule 3: For non-deterministic objects, test their METHODS
// Can't easily test spawn result, but CAN test methods:
x := 42
// => 42
x + 1
// => 43
// For actors: Test the behavior, not the object identity
// Use runtime tests for full stateful actor testing
Rule 4: Use runtime tests for complex actor scenarios
REPL-protocol tests are best for:
- ✅ Primitive operations (arithmetic, strings, booleans)
- ✅ Class loading and compilation
- ✅ Message syntax and parsing
- ⚠️ Limited for: Stateful actor interactions (use
runtime/apps/beamtalk_runtime/test/*.erlinstead)
Example: runtime/apps/beamtalk_runtime/test/beamtalk_actor_tests.erl has comprehensive tests for:
- doesNotUnderstand with proxies
- Concurrent message sends
- State mutations
- Future awaiting
Summary: Add // => assertions for every deterministic result. For actor state tests, use runtime EUnit tests where you have full control.
Nil vs UndefinedObject — the two spellings
Beamtalk has one nil class with two names:
| Name | Where it appears |
|---|---|
UndefinedObject | Canonical class-hierarchy spelling. Used internally by the type checker, BEAM FFI specs, protocol registry, InferredType::known(...), is_assignable_to lookups, Debug output for InferredType, and the stdlib class definition (stdlib/src/UndefinedObject.bt). |
Nil | Source-sympathetic surface spelling. What users type in annotations (:: Nil, -> Nil) and read back in user-facing messages: diagnostics, hover, signature help, code-action inserts, and REPL output. (Beamtalk does not yet support union type annotations in .bt source — Foo | Nil is how the compiler renders a nullable inference, not something you can write as a type.) |
The two are aliased: the type resolver canonicalises Nil/nil → UndefinedObject during annotation resolution (type_resolver.rs), and WellKnownClass::is_nil_class() treats both as the nil type.
When rendering an InferredType, pick the right method for the audience:
InferredType::display_name()— canonical. Use for internal bookkeeping (method-resolution keys, hierarchy enrichment) where the string must round-trip throughis_type_compatible/is_assignable_to.InferredType::display_for_diagnostic()— source-sympathetic. Use for anything the user reads: diagnostic messages, hover contents, signature-help labels, code-action insert text, signature display indisplay_signature().InferredType::class_name_for_diagnostic(name)— when you have a bareEcoString/&str(e.g., union-member names in a hint), use this helper to apply the sameUndefinedObject → Nilrewrite.
The Debug derive on InferredType keeps the canonical UndefinedObject spelling so test failures and logs stay unambiguous.
History: The canonical-vs-surface split is enforced at the rendering boundary because renaming the class in the hierarchy would break BEAM FFI and stdlib protocols (BT-2016 documents the alias history; BT-2066 fixed the rendering regression that leaked UndefinedObject into diagnostics during the BT-2044 narrowing epic).