ADR 0033: Runtime-Embedded Documentation on Class and CompiledMethod

Status

Implemented (2026-02-24)

Context

Problem Statement

Documentation in Beamtalk is currently accessible only through EEP-48 chunks baked into .beam files at compile time (ADR 0008). The :h REPL command fetches docs via code:get_doc/1, parses the docs_v1 tuple, and walks the class hierarchy to find inherited method docs. This works, but it means:

  1. Classes and methods are not self-describing. Counter doc doesn't work — you must go through the :h REPL command or call code:get_doc(counter) and parse the result yourself. CompiledMethod has source and selector but no doc.

  2. Dynamic classes have no docs. Classes created at runtime have no .beam file, so code:get_doc/1 returns {error, missing}. There is no way to attach documentation to a dynamically created class or its methods.

  3. The REPL docs module is over-complex. beamtalk_repl_docs.erl (~540 lines) manually walks the class hierarchy, queries gen_server state, fetches EEP-48 chunks, matches selectors against doc entries by metadata, and formats output. Most of this complexity exists because docs live outside the object model rather than on it.

  4. Docs are not first-class. In Smalltalk, MyClass comment and (MyClass >> #foo) comment are ordinary message sends. In Beamtalk, docs require a special REPL command and a detour through the Erlang code module. This contradicts Principle 6 (Messages All The Way Down) and Principle 8 (Reflection as Primitive).

  5. EEP-48 adds pipeline complexity for little v0.1 value. The current doc pipeline generates EEP-48 chunks in doc_chunks.rs, writes a .docs file alongside Core Erlang output, and injects the chunk into .beam files post-erlc. This is the most fragile part of the compilation pipeline. The primary consumer is :h in the Beamtalk REPL — not Erlang or Elixir tooling. For v0.1, no one is calling h(counter) from the Erlang shell.

Current State

Constraints

Decision

Add documentation strings as first-class state on Behaviour and CompiledMethod, with post-hoc setter messages for programmatic doc assignment. The /// syntax compiles to the same data path as the setters — one unified mechanism for compiled and dynamic classes.

Fix >> to walk the class hierarchy. Currently Counter >> #class returns nil because #class is inherited from ProtoObject. This is inconsistent with Pharo (where >> walks the hierarchy) and makes method doc access unnecessarily difficult. After this change, Counter >> #class returns ProtoObject's CompiledMethod for #class, and (Counter >> #class) doc just works.

Remove EEP-48 doc chunk generation. Runtime-embedded docs replace EEP-48 as the single source of truth for documentation. This eliminates the doc_chunks.rs codegen, the .docs file intermediate, and the post-erlc beam chunk injection step. EEP-48 generation can be re-added later if BEAM ecosystem interop becomes a priority — the data is all available in class_state.

Runtime Storage

class_state record gains a doc field and per-method docs in method_info():

-record(class_state, {
    %% ... existing fields ...
    doc = none :: binary() | none,                     % Class doc string
    %% method_info() gains:  doc => binary() | none
}).

CompiledMethod map gains __doc__:

-type compiled_method() :: #{
    '$beamtalk_class' := 'CompiledMethod',
    '__selector__' := atom(),
    '__source__' := binary(),
    '__doc__' := binary() | nil,                        % NEW
    '__method_info__' := map()
}.

When constructing a CompiledMethod via handle_call({method, Selector}, ...), the __doc__ field is populated from the doc key in the local method_info() map. If the method has no doc, __doc__ is nil.

Note on none vs nil: The class_state record uses Erlang's none atom (idiomatic for internal Erlang records), while the Beamtalk-facing CompiledMethod map uses nil (Beamtalk's null value). The translation happens at the boundary when constructing CompiledMethod from class state.

Beamtalk API

Reading docs — ordinary message sends:

Counter doc                      // => 'A counter actor that maintains state.'
(Counter >> #increment) doc      // => 'Increment the counter by 1.'
(Counter >> #class) doc          // => 'Return the class of the receiver.' (inherited)

Counter doc returns the class doc string or nil. The >> operator walks the class hierarchy (matching Pharo's behaviour), so (Counter >> #class) doc returns ProtoObject's doc for #class even though Counter doesn't define it locally.

Setting docs — post-hoc setters on Behaviour:

Counter doc: "A counter actor that maintains state.".
Counter setDocForMethod: #increment to: "Increment the counter by 1.".

These are ordinary message sends to the class object, handled by methods on Behaviour. They update the class gen_server state. Doc setters only work for locally defined methods — you cannot set a doc on an inherited method without first overriding the method itself.

>> Hierarchy Walking

The >> operator is changed to walk the class hierarchy, matching Pharo's semantics. Previously it only checked local methods.

Counter >> #increment   // => CompiledMethod (local — works today)
Counter >> #class       // => CompiledMethod (inherited from ProtoObject — previously nil)

Implementation: beamtalk_method_resolver:resolve/2 is updated to walk the superclass chain via the class gen_server, checking local methods at each level. When an inherited method is found, the returned CompiledMethod includes the __doc__ from the defining class's method_info().

Doc Write/Read Asymmetry

Doc reading walks the hierarchy via >>; doc writing is local-only via setDocForMethod:to:. This mirrors method dispatch — you can call inherited methods but only define methods locally.

Behaviour.bt Additions

// In lib/Behaviour.bt — documentation protocol

/// Return the documentation string for this class, or nil if none.
///
/// ## Examples
/// ```beamtalk
/// Integer doc       // => "Integer — Whole number arithmetic and operations..."
/// ```
sealed doc => @intrinsic classDoc

/// Set the documentation string for this class.
///
/// ## Examples
/// ```beamtalk
/// Counter doc: "A counter actor".
/// Counter doc   // => "A counter actor"
/// ```
sealed doc: aString => @intrinsic classSetDoc

/// Set the documentation string for a locally defined method.
/// The method must exist in this class (not inherited).
///
/// ## Examples
/// ```beamtalk
/// Counter setDocForMethod: #increment to: "Increment by 1".
/// (Counter >> #increment) doc   // => "Increment by 1"
/// ```
sealed setDocForMethod: selector to: aString => @intrinsic classSetMethodDoc

CompiledMethod Addition

In beamtalk_compiled_method_ops.erl, add doc to the built-in dispatch:

(Counter >> #increment) doc            // => 'Increment the counter by 1.'
(Counter >> #increment) selector       // => #increment
(Counter >> #increment) source         // => 'increment => self.count := ...'
(Counter >> #class) doc                // => 'Return the class of the receiver.'

Compiler Integration

The /// syntax compiles to doc fields in the ClassInfo map passed to beamtalk_object_class:start/2 during module init. No separate setter calls needed at init time — the existing registration path carries docs alongside methods.

In generate_register_class codegen (methods.rs), the ClassInfo map gains:

#{
    %% ... existing fields ...
    doc => <<"A counter actor that maintains state.">>,
    method_docs => #{
        increment => <<"Increment the counter by 1.">>,
        'getValue' => <<"Return the current counter value.">>
    }
}

REPL Session

>> Counter doc
=> "A counter actor that maintains state."

>> (Counter >> #increment) doc
=> "Increment the counter by 1."

>> // Inherited method docs — >> walks the hierarchy
>> (Counter >> #class) doc
=> "Return the class of the receiver."

>> Counter doc: "Updated documentation."
=> "Updated documentation."

>> Counter doc
=> "Updated documentation."

>> Counter setDocForMethod: #increment to: "Add one to the count."
=> "Add one to the count."

>> (Counter >> #increment) doc
=> "Add one to the count."

>> // Dynamic class — docs work without .beam files
>> Greeter doc: "A simple greeter actor."
=> "A simple greeter actor."

>> Greeter setDocForMethod: #greet to: "Return a greeting."
=> "Return a greeting."

>> (Greeter >> #greet) doc
=> "Return a greeting."

Error Examples

>> Counter setDocForMethod: #nonExistent to: "docs for missing method"
=> Error: method #nonExistent is not defined locally on Counter

>> Counter setDocForMethod: #class to: "override inherited doc"
=> Error: method #class is not defined locally on Counter

>> 42 doc
=> Error: 42 does not understand #doc

Prior Art

LanguageClass Doc AccessMethod Doc AccessMutable?Dynamic Classes
PharoMyClass comment(MyClass >> #foo) commentYes (comment:)Yes (via changes file)
PythonMyClass.__doc__MyClass.foo.__doc__Yes (__doc__ = ...)Yes (in memory)
ElixirCode.fetch_docs(M)Code.fetch_docs(M)No (compile-time only)No
RubyNone nativeMethod#source_location onlyNoNo
NewspeakMirror reflectionMirror reflectionVia mirrorsYes (in image)

What we adopt:

What we adapt:

What we reject:

User Impact

Newcomer (from Python/JS/Ruby)

Smalltalk Developer

Erlang/BEAM Developer

Production Operator

Steelman Analysis

Alternative A: Keep EEP-48 as Primary (Lazy Accessor)

CohortStrongest argument
BEAM veteran"EEP-48 is the BEAM standard. Every BEAM language uses it. Removing it means Beamtalk docs are invisible to h/1 in Erlang and Elixir shells, to ExDoc, to any tool that reads beam chunks. You're building a walled garden for no reason — EEP-48 already works."
Operator"Zero memory overhead for docs in production. Docs live on disk in .beam files, loaded only when someone asks. Why pay the RAM cost for something that's only used during development?"

Alternative C: Compiler-Only (Enrich ClassInfo, No Setters)

CohortStrongest argument
Language designer"Mutable docs are a foot-gun. Someone sets Integer doc: 'lol' and now the system lies. Docs should be authoritative — they come from source code, period. Make doc read-only and populate it from /// during compilation."
Pragmatist"Post-hoc setters are YAGNI for v0.1. Dynamic classes barely exist yet. Just thread docs through ClassInfo and add setters if/when dynamic classes need them."

Tension Points

Alternatives Considered

Alternative A: Keep EEP-48 Alongside Runtime Docs

Continue generating EEP-48 chunks and add runtime docs as a parallel storage path.

Rejected because: Two sources of truth that can diverge. Post-hoc doc: mutations make EEP-48 stale. The :h command needs to decide which source to trust. The EEP-48 pipeline (doc_chunks.rs, .docs file, post-erlc injection) is the most fragile part of compilation. All this complexity for a feature no v0.1 user needs (Erlang shell doc interop).

Alternative B: Lazy EEP-48 Accessor Only

Add a doc message on Behaviour and CompiledMethod that calls code:get_doc/1 on demand. No new state, no setters, keep EEP-48 as the only storage.

Counter doc   // => calls code:get_doc(counter), parses docs_v1 tuple

Rejected because: Doesn't work for dynamic classes (no .beam file). Also makes doc inconsistent — it works for compiled classes but returns nil for dynamic ones, with no way to fix it. The whole point is making all classes self-describing regardless of origin.

Alternative C: Compiler-Only (No Setters)

Thread doc strings through the ClassInfo map during register_class/0 (like method source today), but provide only a read-only doc message. No doc: or setDocForMethod:to:.

Counter doc                     // => works (populated at compile time)
Counter doc: "new docs"          // => Error: does not understand #doc:

Rejected because: Breaks the Smalltalk principle that classes are live, mutable objects. Pharo's comment: is essential for interactive development — you fix a doc typo in the browser, not by recompiling. More practically, dynamic classes would have no way to get docs at all. The setters are simple (two intrinsics) and the flexibility is worth it.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Runtime Storage, Intrinsics, and Beamtalk API (M)

Affected components: Runtime (beamtalk_runtime), stdlib (lib/)

  1. Add doc = none :: binary() | none to #class_state{} in beamtalk_object_class.erl
  2. Add doc => binary() | none to method_info() maps
  3. Add __doc__ field to CompiledMethod map construction (in handle_call({method, Selector}, ...)), populated from the doc key in local method_info()
  4. Add doc dispatch to beamtalk_compiled_method_ops.erl
  5. Handle doc and method_docs keys in ClassInfo map during init/1 and handle_call({update_class, ...}, ...)
  6. Fix >> to walk the class hierarchy: Update beamtalk_method_resolver:resolve/2 to walk the superclass chain when a selector is not found in the local method dict. Return the CompiledMethod from the defining class (with its __doc__).
  7. Implement two intrinsics in beamtalk_behaviour_intrinsics.erl:
    • classDoc/1 — read doc from class_state
    • classSetDoc/2 — write doc to class_state
  8. Add setDocForMethod:to: as a gen_server call handler in beamtalk_object_class.erl — writes doc into method_info() for a given selector (error if selector not in local methods)
  9. Add doc, doc:, setDocForMethod:to: methods to lib/Behaviour.bt
  10. Register intrinsics in the compiler's intrinsic table
  11. Add tests: >> walks hierarchy, CompiledMethod doc access, roundtrip via setters, error on non-local method

Phase 2: Compiler Integration (M)

Affected components: Codegen (beamtalk-core)

  1. In methods.rs (generate_register_class), emit doc and method_docs keys in the ClassInfo map, populated from ClassDefinition.doc_comment and MethodDefinition.doc_comment
  2. Ensure update_class (hot reload path) also carries updated docs, re-syncing runtime docs with source
  3. Add tests verifying doc roundtrip: /// → compile → Counter doc returns the text

Phase 3: Remove EEP-48 and Simplify REPL Docs (M)

Affected components: Codegen (beamtalk-core), workspace (beamtalk_workspace), compile escript

  1. Remove doc_chunks.rs from codegen
  2. Remove .docs file generation from the compile pipeline
  3. Remove EEP-48 chunk injection from the compile escript
  4. Rewrite beamtalk_repl_docs.erl to use doc messages and >> exclusively — remove all code:get_doc/1 calls and EEP-48 parsing
  5. Simplify format_class_docs/1 and format_method_doc/2 to delegate to the object protocol
  6. Simplify the docs REPL op in beamtalk_repl_ops_dev.erl — currently calls beamtalk_repl_docs:format_class_docs/1 and format_method_doc/2; after this, delegates to ClassName doc / (ClassName >> #selector) doc message sends. The MCP docs tool benefits automatically (unchanged API, simpler backend).
  7. Update any tests that assert on EEP-48 chunk contents

MCP Integration

The MCP server (beamtalk-mcp) already exposes a docs tool that calls the REPL docs op, which currently delegates to beamtalk_repl_docs.erl and its EEP-48 parsing. After this ADR, the docs REPL op simplifies to message sends on live objects — the same path as :h in the REPL.

Current flow (EEP-48):

MCP docs tool → REPL "docs" op → beamtalk_repl_docs → code:get_doc/1EEP-48 chunk parsing

After this ADR:

MCP docs tool → REPL "docs" op → ClassName doc / (ClassName >> #selector) doc

The MCP docs tool's interface (class, selector params) remains unchanged — agents won't notice the difference. But the backend becomes dramatically simpler: format_class_docs(ClassName) becomes a single ClassName doc message send, and format_method_doc(ClassName, Selector) becomes (ClassName >> Selector) doc.

This also means the MCP docs tool works for dynamic classes automatically — no .beam file needed.

Example MCP interaction (unchanged API, new backend):

{"op": "docs", "class": "Counter"}
→ {"docs": "A counter actor that maintains state."}

{"op": "docs", "class": "Counter", "selector": "increment"}
→ {"docs": "Increment the counter by 1."}

{"op": "docs", "class": "Counter", "selector": "class"}
→ {"docs": "Return the class of the receiver."}  // inherited — >> walks hierarchy

The MCP info tool also benefits — its documentation field in the response currently calls the same EEP-48 path and will switch to the object protocol.

Future Considerations

Implementation Tracking

Epic: BT-768 Issues: BT-769, BT-770, BT-771, BT-772 Status: Planned

PhaseIssueTitleSize
1BT-769Add doc storage, intrinsics, and Behaviour.bt methodsM
2BT-770Fix >> to walk the class hierarchyS
3BT-771Emit doc fields in ClassInfo codegenM
4BT-772Remove EEP-48 and simplify REPL/MCP docsM

References